diff --git a/packages/ixo-transaction/package.json b/packages/ixo-transaction/package.json new file mode 100644 index 00000000..31747ab7 --- /dev/null +++ b/packages/ixo-transaction/package.json @@ -0,0 +1,70 @@ +{ + "name": "ixo-transaction", + "version": "0.1.0", + "private": false, + "description": "Qiforge plugin and typed schemas for validating IXO transactions and dispatching Portal wallet signing", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/ixoworld/qiforge.git" + }, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./qiforge": { + "types": "./dist/qiforge/index.d.ts", + "import": "./dist/qiforge/index.js" + }, + "./react": { + "types": "./dist/react/index.d.ts", + "import": "./dist/react/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "validate": "tsx scripts/validate-ixo-tx.ts", + "render": "tsx scripts/render-signing-action.ts", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@ixo/common": "workspace:*", + "zod": "^4.4.3", + "zod3": "npm:zod@^3.25.0" + }, + "peerDependencies": { + "@ixo/impactxclient-sdk": "^2.3.1", + "@ixo/oracle-runtime": "^1.0.32", + "@ixo/oracles-client-sdk": "^1.2.2" + }, + "peerDependenciesMeta": { + "@ixo/impactxclient-sdk": { + "optional": true + }, + "@ixo/oracles-client-sdk": { + "optional": true + } + }, + "devDependencies": { + "@ixo/impactxclient-sdk": "^2.4.1", + "@ixo/oracle-runtime": "workspace:*", + "@ixo/oracles-client-sdk": "workspace:*", + "@ixo/typescript-config": "workspace:*", + "@ixo/vitest-config": "workspace:*", + "@types/node": "^22.10.5", + "tsx": "^4.20.3", + "typescript": "^5.7.3", + "vitest": "^3.2.4" + } +} diff --git a/packages/ixo-transaction/scripts/render-signing-action.ts b/packages/ixo-transaction/scripts/render-signing-action.ts new file mode 100644 index 00000000..11fbb9be --- /dev/null +++ b/packages/ixo-transaction/scripts/render-signing-action.ts @@ -0,0 +1,3 @@ +import { runCli } from '../src/cli.js'; + +runCli('render'); diff --git a/packages/ixo-transaction/scripts/validate-ixo-tx.ts b/packages/ixo-transaction/scripts/validate-ixo-tx.ts new file mode 100644 index 00000000..edd749f5 --- /dev/null +++ b/packages/ixo-transaction/scripts/validate-ixo-tx.ts @@ -0,0 +1,3 @@ +import { runCli } from '../src/cli.js'; + +runCli('validate'); diff --git a/packages/ixo-transaction/src/action.ts b/packages/ixo-transaction/src/action.ts new file mode 100644 index 00000000..e2908aff --- /dev/null +++ b/packages/ixo-transaction/src/action.ts @@ -0,0 +1,187 @@ +import { z } from 'zod'; + +import { + ITrxMsgSchema, + NetworkSchema, + RiskConfirmationSchema, + TestnetReceiptSchema, + TransactionDraftSchema, + type ITrxMsg, +} from './schemas.js'; +import { validateTransactionDraft } from './validate.js'; + +export const SIGN_TRANSACTION_ACTION_NAME = 'sign_transaction'; + +export const SIGN_TRANSACTION_ACTION_DESCRIPTION = + 'Sign a validated IXO transaction in the user Portal wallet.'; + +export const IntentActionMetadataSchema = z + .object({ + source: z.enum([ + 'slash-command', + 'natural-language', + 'type-url', + 'explicit-route', + ]), + module: z.string().min(1), + action: z.string().min(1), + messageName: z.string().min(1), + typeUrl: z.string().regex(/^\/[A-Za-z0-9.]+\.Msg[A-Za-z0-9]+$/), + confidence: z.number().min(0).max(1), + ambiguities: z.array(z.string()), + }) + .strict(); + +export const SignTransactionActionArgsSchema = z + .object({ + action: z.literal(SIGN_TRANSACTION_ACTION_NAME), + network: NetworkSchema, + messages: z.array(ITrxMsgSchema).min(1), + memo: z.string().optional(), + intent: IntentActionMetadataSchema, + risks: z.array(z.string()), + riskLevel: z.enum(['low', 'medium', 'high', 'critical']), + requiresConfirmation: z.boolean(), + riskConfirmation: RiskConfirmationSchema.optional(), + testnetReceipt: TestnetReceiptSchema.optional(), + overrideMainnet: z.boolean().optional(), + overrideReason: z.string().min(1).optional(), + }) + .strict(); + +export type SignTransactionActionArgs = z.infer< + typeof SignTransactionActionArgsSchema +>; + +export const SignTransactionActionResultSchema = z + .object({ + success: z.boolean(), + transactionHash: z.string().min(1).optional(), + code: z.number().int().optional(), + height: z.union([z.string(), z.number().int()]).optional(), + error: z.string().optional(), + result: z.unknown().optional(), + }) + .strict(); + +export type SignTransactionActionResult = z.infer< + typeof SignTransactionActionResultSchema +>; + +export type WalletSignTransactionFn = ( + messages: readonly ITrxMsg[], + memo?: string, +) => Promise; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readStringField( + value: Record, + fields: readonly string[], +): string | undefined { + for (const field of fields) { + const candidate = value[field]; + if (typeof candidate === 'string' && candidate.length > 0) { + return candidate; + } + } + return undefined; +} + +function readNumberField( + value: Record, + fields: readonly string[], +): number | undefined { + for (const field of fields) { + const candidate = value[field]; + if (typeof candidate === 'number' && Number.isInteger(candidate)) { + return candidate; + } + } + return undefined; +} + +export function buildSignTransactionActionArgs( + input: unknown, +): SignTransactionActionArgs { + const draft = TransactionDraftSchema.parse(input); + const validated = validateTransactionDraft(draft, { + requireRiskConfirmation: true, + }); + + return SignTransactionActionArgsSchema.parse({ + action: SIGN_TRANSACTION_ACTION_NAME, + network: validated.network, + messages: [validated.message], + memo: draft.memo, + intent: validated.intent, + risks: validated.risks, + riskLevel: validated.riskLevel, + requiresConfirmation: validated.requiresConfirmation, + riskConfirmation: draft.riskConfirmation, + testnetReceipt: draft.testnetReceipt, + overrideMainnet: draft.overrideMainnet, + overrideReason: draft.overrideReason, + }); +} + +export function normalizeWalletSignResult( + result: unknown, +): SignTransactionActionResult { + if (result === undefined || result === null) { + return SignTransactionActionResultSchema.parse({ + success: false, + error: 'Portal wallet did not return a transaction result', + }); + } + + if (!isRecord(result)) { + return SignTransactionActionResultSchema.parse({ + success: true, + result, + }); + } + + const transactionHash = readStringField(result, [ + 'transactionHash', + 'txHash', + 'hash', + ]); + const code = readNumberField(result, ['code']); + const height = + readStringField(result, ['height']) ?? readNumberField(result, ['height']); + const explicitSuccess = + typeof result.success === 'boolean' ? result.success : undefined; + const success = explicitSuccess ?? (code === undefined || code === 0); + const error = success + ? undefined + : (readStringField(result, ['error', 'rawLog', 'log']) ?? + `Transaction failed with code ${code ?? 'unknown'}`); + + return SignTransactionActionResultSchema.parse({ + success, + transactionHash, + code, + height, + error, + result, + }); +} + +export async function signIxoTransactionWithWallet( + input: unknown, + transactSignX: WalletSignTransactionFn, +): Promise { + try { + const args = SignTransactionActionArgsSchema.parse(input); + const result = await transactSignX(args.messages, args.memo); + return normalizeWalletSignResult(result); + } catch (error) { + return SignTransactionActionResultSchema.parse({ + success: false, + error: error instanceof Error ? error.message : 'Wallet signing failed', + }); + } +} diff --git a/packages/ixo-transaction/src/catalog.ts b/packages/ixo-transaction/src/catalog.ts new file mode 100644 index 00000000..7bb8e443 --- /dev/null +++ b/packages/ixo-transaction/src/catalog.ts @@ -0,0 +1,657 @@ +import type { FieldKind } from './schemas.js'; + +export type RiskLevel = 'low' | 'medium' | 'high' | 'critical'; + +export type FieldSpec = { + name: string; + kind: FieldKind; + required?: boolean; +}; + +export type MessageSpec = { + module: string; + action: string; + messageName: string; + typeUrl: string; + fields: FieldSpec[]; + risks: string[]; + riskLevel: RiskLevel; +}; + +const opt = (name: string, kind: FieldKind): FieldSpec => ({ name, kind }); +const req = (name: string, kind: FieldKind): FieldSpec => ({ + name, + kind, + required: true, +}); + +/** + * Modules whose `Msg`s are query-only or out of v1 scope. Surfaced to the agent + * so it can explain why a route is unavailable instead of guessing. + */ +export const QUERY_ONLY_MODULES = ['epochs', 'mint'] as const; +export const DEFERRED_MODULES = ['bonds', 'liquidstake', 'names'] as const; + +type Entry = [ + action: string, + messageName: string, + fields: FieldSpec[], + riskLevel: RiskLevel, + risks: string[], +]; + +function buildModule( + module: string, + version: string, + entries: Entry[], +): MessageSpec[] { + return entries.map(([action, messageName, fields, riskLevel, risks]) => ({ + module, + action, + messageName, + typeUrl: `/ixo.${version}.${messageName}`, + fields, + risks, + riskLevel, + })); +} + +const SIGNER = req('signer', 'address'); + +/** + * Canonical catalog of v1-supported IXO transaction messages. Field names and + * shapes are verified against `@ixo/impactxclient-sdk` protobuf definitions. + * `names`, `bonds`, `liquidstake`, and generic Cosmos authz are intentionally + * out of v1 (see `DEFERRED_MODULES`). + */ +export const MESSAGE_CATALOG: readonly MessageSpec[] = [ + ...buildModule('entity', 'entity.v1beta1', [ + [ + 'create', + 'MsgCreateEntity', + [ + req('entityType', 'string'), + opt('entityStatus', 'int'), + opt('controller', 'didArray'), + opt('context', 'contextArray'), + req('verification', 'verificationArray'), + opt('service', 'serviceArray'), + opt('accordedRight', 'accordedRightArray'), + opt('linkedResource', 'linkedResourceArray'), + opt('linkedEntity', 'linkedEntityArray'), + opt('linkedClaim', 'linkedClaimArray'), + opt('startDate', 'timestamp'), + opt('endDate', 'timestamp'), + req('relayerNode', 'did'), + opt('credentials', 'stringArray'), + req('ownerDid', 'did'), + req('ownerAddress', 'address'), + opt('data', 'bytes'), + opt('alsoKnownAs', 'string'), + ], + 'high', + [ + 'Creates a new entity, admin account, DID document, and ownership NFT. The entity DID is chain-derived and cannot be chosen.', + ], + ], + [ + 'update', + 'MsgUpdateEntity', + [ + req('id', 'did'), + opt('entityStatus', 'int'), + opt('startDate', 'timestamp'), + opt('endDate', 'timestamp'), + opt('credentials', 'stringArray'), + req('controllerDid', 'did'), + req('controllerAddress', 'address'), + ], + 'high', + [ + 'Overwrites mutable entity fields. Omitted fields can reset to chain zero values if a partial update was intended.', + ], + ], + [ + 'update-verified', + 'MsgUpdateEntityVerified', + [ + req('id', 'did'), + req('entityVerified', 'bool'), + req('relayerNodeDid', 'did'), + req('relayerNodeAddress', 'address'), + ], + 'critical', + [ + 'Changes entity verification status. Only the stored relayer node can perform this transaction.', + ], + ], + [ + 'transfer', + 'MsgTransferEntity', + [ + req('id', 'did'), + req('ownerDid', 'did'), + req('ownerAddress', 'address'), + req('recipientDid', 'did'), + ], + 'critical', + [ + 'Transfers the entity ownership NFT and rewrites controllers to the recipient. Irreversible without the recipient transferring back.', + ], + ], + [ + 'create-account', + 'MsgCreateEntityAccount', + [req('id', 'did'), req('name', 'string'), req('ownerAddress', 'address')], + 'medium', + ['Creates a deterministic module account controlled by the entity.'], + ], + [ + 'grant-account-authz', + 'MsgGrantEntityAccountAuthz', + [ + req('id', 'did'), + req('name', 'string'), + req('granteeAddress', 'address'), + req('grant', 'authzGrant'), + req('ownerAddress', 'address'), + ], + 'critical', + [ + 'Grants another address authority from an entity account. Scope, expiration, and message type must be reviewed.', + ], + ], + [ + 'revoke-account-authz', + 'MsgRevokeEntityAccountAuthz', + [ + req('id', 'did'), + req('name', 'string'), + req('granteeAddress', 'address'), + req('msgTypeUrl', 'string'), + req('ownerAddress', 'address'), + ], + 'high', + [ + 'Revokes entity-account authority and may interrupt dependent automation.', + ], + ], + ]), + + ...buildModule('iid', 'iid.v1beta1', [ + [ + 'create', + 'MsgCreateIidDocument', + [ + req('id', 'did'), + opt('controllers', 'didArray'), + opt('context', 'contextArray'), + opt('verifications', 'verificationArray'), + opt('services', 'serviceArray'), + opt('accordedRight', 'accordedRightArray'), + opt('linkedResource', 'linkedResourceArray'), + opt('linkedEntity', 'linkedEntityArray'), + opt('linkedClaim', 'linkedClaimArray'), + opt('alsoKnownAs', 'string'), + SIGNER, + ], + 'critical', + [ + 'Creates a new IID/DID document. Confirm controllers and signer authority.', + ], + ], + [ + 'update', + 'MsgUpdateIidDocument', + [ + req('id', 'did'), + opt('controllers', 'didArray'), + opt('context', 'contextArray'), + opt('verifications', 'verificationArray'), + opt('services', 'serviceArray'), + opt('accordedRight', 'accordedRightArray'), + opt('linkedResource', 'linkedResourceArray'), + opt('linkedEntity', 'linkedEntityArray'), + opt('linkedClaim', 'linkedClaimArray'), + opt('alsoKnownAs', 'string'), + SIGNER, + ], + 'high', + ['Overwrites the IID document. Omitted fields may reset to zero values.'], + ], + [ + 'add-verification', + 'MsgAddVerification', + [req('id', 'did'), req('verification', 'verification'), SIGNER], + 'high', + [ + 'Adds a verification method/key. Wrong keys can grant unwanted control.', + ], + ], + [ + 'revoke-verification', + 'MsgRevokeVerification', + [req('id', 'did'), req('methodId', 'string'), SIGNER], + 'high', + [ + 'Revokes a verification method. Can lock out a controller if mis-targeted.', + ], + ], + [ + 'set-verification-relationships', + 'MsgSetVerificationRelationships', + [ + req('id', 'did'), + req('methodId', 'string'), + req('relationships', 'stringArray'), + SIGNER, + ], + 'high', + ['Changes which relationships a verification method authorizes.'], + ], + [ + 'add-service', + 'MsgAddService', + [req('id', 'did'), req('serviceData', 'service'), SIGNER], + 'medium', + ['Adds a service endpoint to the IID document.'], + ], + [ + 'delete-service', + 'MsgDeleteService', + [req('id', 'did'), req('serviceId', 'string'), SIGNER], + 'medium', + ['Removes a service endpoint from the IID document.'], + ], + [ + 'add-controller', + 'MsgAddController', + [req('id', 'did'), req('controllerDid', 'did'), SIGNER], + 'critical', + ['Adds a controller DID — grants full control over the identity.'], + ], + [ + 'delete-controller', + 'MsgDeleteController', + [req('id', 'did'), req('controllerDid', 'did'), SIGNER], + 'critical', + ['Removes a controller DID. Can lock users out of the identity.'], + ], + [ + 'add-linked-resource', + 'MsgAddLinkedResource', + [req('id', 'did'), req('linkedResource', 'linkedResource'), SIGNER], + 'medium', + ['Attaches a linked resource to the IID document.'], + ], + [ + 'delete-linked-resource', + 'MsgDeleteLinkedResource', + [req('id', 'did'), req('resourceId', 'string'), SIGNER], + 'medium', + ['Removes a linked resource from the IID document.'], + ], + [ + 'add-linked-claim', + 'MsgAddLinkedClaim', + [req('id', 'did'), req('linkedClaim', 'linkedClaim'), SIGNER], + 'medium', + ['Attaches a linked claim to the IID document.'], + ], + [ + 'delete-linked-claim', + 'MsgDeleteLinkedClaim', + [req('id', 'did'), req('claimId', 'string'), SIGNER], + 'medium', + ['Removes a linked claim from the IID document.'], + ], + [ + 'add-linked-entity', + 'MsgAddLinkedEntity', + [req('id', 'did'), req('linkedEntity', 'linkedEntity'), SIGNER], + 'medium', + ['Attaches a linked entity relationship to the IID document.'], + ], + [ + 'delete-linked-entity', + 'MsgDeleteLinkedEntity', + [req('id', 'did'), req('entityId', 'string'), SIGNER], + 'medium', + ['Removes a linked entity relationship from the IID document.'], + ], + [ + 'add-accorded-right', + 'MsgAddAccordedRight', + [req('id', 'did'), req('accordedRight', 'accordedRight'), SIGNER], + 'medium', + ['Adds an accorded right to the IID document.'], + ], + [ + 'delete-accorded-right', + 'MsgDeleteAccordedRight', + [req('id', 'did'), req('rightId', 'string'), SIGNER], + 'medium', + ['Removes an accorded right from the IID document.'], + ], + [ + 'add-context', + 'MsgAddIidContext', + [req('id', 'did'), req('context', 'context'), SIGNER], + 'medium', + ['Adds a context entry to the IID document.'], + ], + [ + 'delete-context', + 'MsgDeleteIidContext', + [req('id', 'did'), req('contextKey', 'string'), SIGNER], + 'medium', + ['Removes a context entry from the IID document.'], + ], + [ + 'deactivate', + 'MsgDeactivateIID', + [req('id', 'did'), req('state', 'bool'), SIGNER], + 'critical', + ['Deactivates (or reactivates) the IID document.'], + ], + ]), + + ...buildModule('claims', 'claims.v1beta1', [ + [ + 'create-collection', + 'MsgCreateCollection', + [ + req('entity', 'did'), + SIGNER, + opt('protocol', 'did'), + opt('startDate', 'timestamp'), + opt('endDate', 'timestamp'), + opt('quota', 'uint'), + opt('state', 'int'), + opt('payments', 'json'), + opt('intents', 'int'), + ], + 'high', + ['Creates a claim collection. Confirm entity, payments, and quota.'], + ], + [ + 'submit', + 'MsgSubmitClaim', + [ + req('collectionId', 'string'), + req('claimId', 'string'), + req('agentDid', 'did'), + req('agentAddress', 'address'), + req('adminAddress', 'address'), + opt('useIntent', 'bool'), + opt('amount', 'coinArray'), + opt('cw20Payment', 'jsonArray'), + ], + 'high', + ['Submits a claim. May trigger payment workflows.'], + ], + [ + 'evaluate', + 'MsgEvaluateClaim', + [ + req('claimId', 'string'), + req('collectionId', 'string'), + req('oracle', 'did'), + req('agentDid', 'did'), + req('agentAddress', 'address'), + req('adminAddress', 'address'), + req('status', 'int'), + opt('reason', 'int'), + opt('verificationProof', 'string'), + opt('amount', 'coinArray'), + opt('cw20Payment', 'jsonArray'), + ], + 'critical', + [ + 'Sets a claim evaluation status, which can release payments. Confirm status code and amounts.', + ], + ], + [ + 'dispute', + 'MsgDisputeClaim', + [ + req('subjectId', 'string'), + req('agentDid', 'did'), + req('agentAddress', 'address'), + req('disputeType', 'int'), + opt('data', 'json'), + ], + 'high', + ['Raises a dispute against a claim or evaluation.'], + ], + [ + 'update-collection-state', + 'MsgUpdateCollectionState', + [ + req('collectionId', 'string'), + req('state', 'int'), + req('adminAddress', 'address'), + ], + 'high', + ['Changes collection state (e.g. open/paused/closed).'], + ], + [ + 'update-collection-dates', + 'MsgUpdateCollectionDates', + [ + req('collectionId', 'string'), + opt('startDate', 'timestamp'), + opt('endDate', 'timestamp'), + req('adminAddress', 'address'), + ], + 'medium', + ['Changes a collection validity window.'], + ], + [ + 'update-collection-payments', + 'MsgUpdateCollectionPayments', + [ + req('collectionId', 'string'), + req('payments', 'json'), + req('adminAddress', 'address'), + ], + 'critical', + [ + 'Changes collection payment configuration. Confirm amounts and recipients.', + ], + ], + [ + 'update-collection-intents', + 'MsgUpdateCollectionIntents', + [ + req('collectionId', 'string'), + req('intents', 'int'), + req('adminAddress', 'address'), + ], + 'high', + ['Changes collection intent options.'], + ], + [ + 'claim-intent', + 'MsgClaimIntent', + [ + req('agentDid', 'did'), + req('agentAddress', 'address'), + req('collectionId', 'string'), + opt('amount', 'coinArray'), + opt('cw20Payment', 'jsonArray'), + ], + 'medium', + ['Reserves an intent to submit a claim against a collection.'], + ], + [ + 'create-claim-authorization', + 'MsgCreateClaimAuthorization', + [ + req('creatorAddress', 'address'), + req('creatorDid', 'did'), + req('granteeAddress', 'address'), + req('adminAddress', 'address'), + req('collectionId', 'string'), + req('authType', 'int'), + req('agentQuota', 'uint'), + opt('maxAmount', 'coinArray'), + opt('maxCw20Payment', 'jsonArray'), + opt('expiration', 'timestamp'), + opt('intentDurationNs', 'json'), + opt('beforeDate', 'timestamp'), + ], + 'critical', + [ + 'Authorizes an agent to submit/evaluate claims with payment limits. Confirm grantee, quota, and max amounts.', + ], + ], + ]), + + ...buildModule('token', 'token.v1beta1', [ + [ + 'create', + 'MsgCreateToken', + [ + req('minter', 'address'), + req('class', 'string'), + req('name', 'string'), + opt('description', 'string'), + opt('image', 'string'), + req('tokenType', 'string'), + opt('cap', 'string'), + ], + 'critical', + [ + 'Creates a new impact-credit token class. Confirm class, type, and cap.', + ], + ], + [ + 'mint', + 'MsgMintToken', + [ + req('minter', 'address'), + req('contractAddress', 'address'), + req('owner', 'address'), + req('mintBatch', 'jsonArray'), + ], + 'critical', + ['Mints impact credits. Confirm batches, amounts, and owner.'], + ], + [ + 'transfer', + 'MsgTransferToken', + [ + req('owner', 'address'), + req('recipient', 'address'), + req('tokens', 'tokenBatchArray'), + ], + 'critical', + ['Transfers impact credits to another address.'], + ], + [ + 'retire', + 'MsgRetireToken', + [ + req('owner', 'address'), + req('tokens', 'tokenBatchArray'), + req('jurisdiction', 'string'), + req('reason', 'string'), + ], + 'critical', + ['Permanently retires (burns) impact credits. Irreversible.'], + ], + [ + 'transfer-credit', + 'MsgTransferCredit', + [ + req('owner', 'address'), + req('tokens', 'tokenBatchArray'), + req('jurisdiction', 'string'), + opt('reason', 'string'), + req('authorizationId', 'string'), + ], + 'critical', + ['Transfers credits under an authorization. Confirm authorization id.'], + ], + [ + 'cancel', + 'MsgCancelToken', + [ + req('owner', 'address'), + req('tokens', 'tokenBatchArray'), + req('reason', 'string'), + ], + 'critical', + ['Cancels (voids) impact credits. Irreversible.'], + ], + [ + 'pause', + 'MsgPauseToken', + [ + req('minter', 'address'), + req('contractAddress', 'address'), + req('paused', 'bool'), + ], + 'high', + ['Pauses or unpauses a token contract.'], + ], + [ + 'stop', + 'MsgStopToken', + [req('minter', 'address'), req('contractAddress', 'address')], + 'critical', + ['Stops a token contract permanently.'], + ], + ]), + + ...buildModule('smart-account', 'smartaccount.v1beta1', [ + [ + 'add-authenticator', + 'MsgAddAuthenticator', + [ + req('sender', 'address'), + req('authenticatorType', 'string'), + req('data', 'bytes'), + ], + 'critical', + ['Adds an account authenticator. Wrong data can lock the account.'], + ], + [ + 'remove-authenticator', + 'MsgRemoveAuthenticator', + [req('sender', 'address'), req('id', 'uint')], + 'critical', + ['Removes an account authenticator. Can lock the account out.'], + ], + [ + 'set-active-state', + 'MsgSetActiveState', + [req('sender', 'address'), req('active', 'bool')], + 'critical', + ['Enables or disables smart-account authentication globally.'], + ], + ]), +] as const; + +export function findMessageByRoute( + module: string, + action: string, +): MessageSpec | undefined { + return MESSAGE_CATALOG.find( + (entry) => entry.module === module && entry.action === action, + ); +} + +export function findMessageByTypeUrl(typeUrl: string): MessageSpec | undefined { + return MESSAGE_CATALOG.find((entry) => entry.typeUrl === typeUrl); +} + +export function routeForMessageName( + messageName: string, +): MessageSpec | undefined { + const normalized = messageName.toLowerCase(); + return MESSAGE_CATALOG.find( + (entry) => entry.messageName.toLowerCase() === normalized, + ); +} diff --git a/packages/ixo-transaction/src/cli.ts b/packages/ixo-transaction/src/cli.ts new file mode 100644 index 00000000..b9d785ab --- /dev/null +++ b/packages/ixo-transaction/src/cli.ts @@ -0,0 +1,45 @@ +import { readFileSync } from 'node:fs'; +import { inspect } from 'node:util'; +import { ZodError } from 'zod'; + +import { buildSignTransactionActionArgs } from './action.js'; +import { validateTransactionDraft } from './validate.js'; + +export type CliMode = 'validate' | 'render'; + +function readJsonArgument(): unknown { + const inline = process.argv.slice(2).join(' ').trim(); + const raw = inline.length > 0 ? inline : readFileSync(0, 'utf8').trim(); + if (!raw) + throw new Error( + 'Provide a JSON transaction draft argument or pipe JSON on stdin', + ); + return JSON.parse(raw); +} + +function printJson(value: unknown): void { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +export function runCli(mode: CliMode): void { + try { + const input = readJsonArgument(); + if (mode === 'validate') { + printJson(validateTransactionDraft(input)); + return; + } + if (mode === 'render') { + printJson(buildSignTransactionActionArgs(input)); + return; + } + } catch (error) { + const message = + error instanceof ZodError + ? JSON.stringify(error.flatten(), null, 2) + : error instanceof Error + ? error.message + : inspect(error, { depth: 8 }); + process.stderr.write(`${message}\n`); + process.exitCode = 1; + } +} diff --git a/packages/ixo-transaction/src/index.ts b/packages/ixo-transaction/src/index.ts new file mode 100644 index 00000000..b40e4d1c --- /dev/null +++ b/packages/ixo-transaction/src/index.ts @@ -0,0 +1,60 @@ +export { + MESSAGE_CATALOG, + QUERY_ONLY_MODULES, + DEFERRED_MODULES, + findMessageByRoute, + findMessageByTypeUrl, + routeForMessageName, +} from './catalog.js'; +export type { FieldSpec, MessageSpec, RiskLevel } from './catalog.js'; +export { classifyIntent, parseSlashCommand, resolveIntent } from './intent.js'; +export type { IntentResult } from './intent.js'; +export { + IntentActionMetadataSchema, + SIGN_TRANSACTION_ACTION_DESCRIPTION, + SIGN_TRANSACTION_ACTION_NAME, + SignTransactionActionArgsSchema, + SignTransactionActionResultSchema, + buildSignTransactionActionArgs, + normalizeWalletSignResult, + signIxoTransactionWithWallet, +} from './action.js'; +export type { + SignTransactionActionArgs, + SignTransactionActionResult, + WalletSignTransactionFn, +} from './action.js'; +export { + AccordedRightSchema, + AnySchema, + AuthzGrantSchema, + CoinSchema, + ContextSchema, + IntegerStringSchema, + ITrxMsgSchema, + IxoAddressSchema, + IxoDidSchema, + LinkedClaimSchema, + LinkedEntitySchema, + LinkedResourceSchema, + NetworkSchema, + RiskConfirmationSchema, + ServiceSchema, + TestnetReceiptSchema, + TimestampSchema, + TokenBatchSchema, + TransactionDraftSchema, + VerificationMethodSchema, + VerificationSchema, + schemaForFieldKind, +} from './schemas.js'; +export type { + FieldKind, + ITrxMsg, + Network, + RiskConfirmation, + TestnetReceipt, + TransactionDraft, +} from './schemas.js'; +export { validateTransactionDraft } from './validate.js'; +export type { ValidatedTransaction, ValidationOptions } from './validate.js'; diff --git a/packages/ixo-transaction/src/intent.ts b/packages/ixo-transaction/src/intent.ts new file mode 100644 index 00000000..69705c82 --- /dev/null +++ b/packages/ixo-transaction/src/intent.ts @@ -0,0 +1,258 @@ +import { + MESSAGE_CATALOG, + findMessageByRoute, + findMessageByTypeUrl, + routeForMessageName, + type MessageSpec, +} from './catalog.js'; + +export type IntentResult = { + source: 'slash-command' | 'natural-language' | 'type-url' | 'explicit-route'; + module: string; + action: string; + messageName: string; + typeUrl: string; + confidence: number; + ambiguities: string[]; +}; + +const MODULE_ALIASES: Record = { + entity: 'entity', + domain: 'entity', + iid: 'iid', + did: 'iid', + claim: 'claims', + claims: 'claims', + token: 'token', + credit: 'token', + credits: 'token', + smartaccount: 'smart-account', + 'smart-account': 'smart-account', + authenticator: 'smart-account', +}; + +const ACTION_ALIASES: Record = { + addlinkedresource: 'add-linked-resource', + 'add-resource': 'add-linked-resource', + 'attach-resource': 'add-linked-resource', + addlinkedentity: 'add-linked-entity', + 'add-entity': 'add-linked-entity', + createentity: 'create', + msgcreateentity: 'create', + megcreateentity: 'create', + verify: 'update-verified', + verified: 'update-verified', + grant: 'grant-account-authz', + revoke: 'revoke-account-authz', + retirecredits: 'retire', + retirecredit: 'retire', + addauthenticator: 'add-authenticator', + removeauthenticator: 'remove-authenticator', +}; + +const NATURAL_LANGUAGE_RULES: Array<{ + pattern: RegExp; + module: string; + action: string; + confidence: number; +}> = [ + { + pattern: /\b(megcreateentity|msgcreateentity|createentity)\b/i, + module: 'entity', + action: 'create', + confidence: 0.98, + }, + { + pattern: + /\b(create|new|register|set up)\b.*\b(domain|entity|dao|oracle|project|protocol|asset)\b/i, + module: 'entity', + action: 'create', + confidence: 0.92, + }, + { + pattern: /\btransfer\b.*\b(entity|domain|ownership)\b/i, + module: 'entity', + action: 'transfer', + confidence: 0.9, + }, + { + pattern: /\b(verify|mark verified|unverify)\b.*\b(entity|domain)\b/i, + module: 'entity', + action: 'update-verified', + confidence: 0.86, + }, + { + pattern: /\b(add|attach)\b.*\blinked resource\b/i, + module: 'iid', + action: 'add-linked-resource', + confidence: 0.9, + }, + { + pattern: /\b(add|attach)\b.*\blinked entity\b/i, + module: 'iid', + action: 'add-linked-entity', + confidence: 0.9, + }, + { + pattern: /\bsubmit\b.*\bclaim\b/i, + module: 'claims', + action: 'submit', + confidence: 0.9, + }, + { + pattern: /\bevaluate\b.*\bclaim\b/i, + module: 'claims', + action: 'evaluate', + confidence: 0.9, + }, + { + pattern: /\bretire\b.*\b(credit|credits|token|tokens)\b/i, + module: 'token', + action: 'retire', + confidence: 0.92, + }, + { + pattern: /\bmint\b.*\b(credit|credits|token|tokens)\b/i, + module: 'token', + action: 'mint', + confidence: 0.9, + }, + { + pattern: /\btransfer\b.*\b(credit|credits|token|tokens)\b/i, + module: 'token', + action: 'transfer', + confidence: 0.88, + }, + { + pattern: /\b(add|create)\b.*\bauthenticator\b/i, + module: 'smart-account', + action: 'add-authenticator', + confidence: 0.9, + }, +]; + +function normalizeToken(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/_/g, '-') + .replace(/[^a-z0-9-]/g, ''); +} + +function normalizeModule(value: string): string { + const token = normalizeToken(value); + return MODULE_ALIASES[token] ?? token; +} + +function normalizeAction(value: string): string { + const token = normalizeToken(value); + return ACTION_ALIASES[token] ?? token; +} + +function toIntent( + spec: MessageSpec, + source: IntentResult['source'], + confidence: number, + ambiguities: string[] = [], +): IntentResult { + return { + source, + module: spec.module, + action: spec.action, + messageName: spec.messageName, + typeUrl: spec.typeUrl, + confidence, + ambiguities, + }; +} + +export function parseSlashCommand(input: string): IntentResult { + const match = input + .trim() + .match(/^\/ixo\s+([a-z0-9_-]+)\s+([a-z0-9_-]+)(?:\s+.*)?$/i); + if (!match) { + throw new Error( + 'Slash command must use /ixo {message-type} {message-action}', + ); + } + const [, rawModule, rawAction] = match; + if (!rawModule || !rawAction) { + throw new Error( + 'Slash command must use /ixo {message-type} {message-action}', + ); + } + + const module = normalizeModule(rawModule); + const action = normalizeAction(rawAction); + const spec = findMessageByRoute(module, action); + if (!spec) { + throw new Error( + `Unsupported IXO transaction route: /ixo ${module} ${action}`, + ); + } + return toIntent(spec, 'slash-command', 1); +} + +export function classifyIntent(input: string): IntentResult { + const trimmed = input.trim(); + + // A Msg typeUrl also starts with `/`, so resolve it before the slash command. + const typeUrlSpec = findMessageByTypeUrl(trimmed); + if (typeUrlSpec) return toIntent(typeUrlSpec, 'type-url', 1); + + if (trimmed.startsWith('/')) return parseSlashCommand(trimmed); + + const compact = trimmed.replace(/[^A-Za-z0-9]/g, '').toLowerCase(); + const messageNameSpec = routeForMessageName(compact); + if (messageNameSpec) + return toIntent(messageNameSpec, 'natural-language', 0.95); + + for (const rule of NATURAL_LANGUAGE_RULES) { + if (rule.pattern.test(trimmed)) { + const spec = findMessageByRoute(rule.module, rule.action); + if (spec) return toIntent(spec, 'natural-language', rule.confidence); + } + } + + const possible = MESSAGE_CATALOG.filter( + (entry) => + trimmed.toLowerCase().includes(entry.module) || + trimmed.toLowerCase().includes(entry.action), + ); + const ambiguities = possible + .slice(0, 5) + .map((entry) => `/ixo ${entry.module} ${entry.action}`); + throw new Error( + ambiguities.length > 0 + ? `Ambiguous IXO transaction intent. Candidate routes: ${ambiguities.join(', ')}` + : 'Unable to identify an IXO transaction type from the prompt', + ); +} + +export function resolveIntent(input: { + input?: string; + command?: string; + messageType?: string; + action?: string; + typeUrl?: string; +}): IntentResult { + if (input.command) return parseSlashCommand(input.command); + if (input.messageType && input.action) { + const module = normalizeModule(input.messageType); + const action = normalizeAction(input.action); + const spec = findMessageByRoute(module, action); + if (!spec) + throw new Error( + `Unsupported IXO transaction route: /ixo ${module} ${action}`, + ); + return toIntent(spec, 'explicit-route', 1); + } + if (input.typeUrl) { + const spec = findMessageByTypeUrl(input.typeUrl); + if (!spec) + throw new Error(`Unsupported IXO transaction typeUrl: ${input.typeUrl}`); + return toIntent(spec, 'type-url', 1); + } + if (input.input) return classifyIntent(input.input); + throw new Error('No transaction intent was provided'); +} diff --git a/packages/ixo-transaction/src/qiforge/index.ts b/packages/ixo-transaction/src/qiforge/index.ts new file mode 100644 index 00000000..9645dac1 --- /dev/null +++ b/packages/ixo-transaction/src/qiforge/index.ts @@ -0,0 +1,2 @@ +export { IxoTransactionPlugin, createIxoTransactionPlugin } from './plugin.js'; +export { createIxoTransactionTools } from './tools.js'; diff --git a/packages/ixo-transaction/src/qiforge/plugin.ts b/packages/ixo-transaction/src/qiforge/plugin.ts new file mode 100644 index 00000000..688aa4df --- /dev/null +++ b/packages/ixo-transaction/src/qiforge/plugin.ts @@ -0,0 +1,83 @@ +import { + OraclePlugin, + type PluginContext, + type PluginManifest, + type PluginTool, +} from '@ixo/oracle-runtime/plugin-api'; + +import { createIxoTransactionTools } from './tools.js'; + +export class IxoTransactionPlugin extends OraclePlugin { + readonly name = 'ixo-transaction'; + readonly version = '0.1.0'; + readonly manifest: PluginManifest = { + title: 'IXO Transaction', + summary: + "Configure, validate, risk-gate, and dispatch IXO transactions to the user's Portal wallet for signing. Defaults to testnet; requires explicit risk confirmation before risky transactions; the user always signs in their own wallet.", + whenToUse: [ + 'The user asks to prepare an IXO chain transaction for Portal signing.', + 'The user provides a slash command such as /ixo entity create or /ixo token retire.', + 'The user describes an IXO transaction in natural language and needs deterministic Msg routing.', + 'Workflow: classify the intent, collect and validate required fields, disclose the risks, and only call sign_ixo_transaction after the user explicitly confirms. Default to testnet before mainnet.', + ], + whenNotToUse: [ + 'The user only wants to query chain state or inspect account balances.', + 'The user asks the oracle to sign, broadcast, or custody keys directly — signing always happens in the user wallet.', + 'The transaction belongs to a query-only or deferred module (epochs, mint, bonds, liquidstake, names).', + ], + examples: [ + { + user: '/ixo entity create', + thought: + 'Resolve the slash command, collect required fields, validate the draft, report risks, then dispatch to the Portal wallet after confirmation.', + tool: 'list_ixo_transaction_routes', + args: { messageType: 'entity' }, + }, + { + user: 'I want to create a new domain', + thought: + 'Classify natural language to MsgCreateEntity before asking for required parameters.', + tool: 'classify_ixo_transaction_intent', + args: { input: 'I want to create a new domain' }, + }, + { + user: 'Sign the mainnet transfer after this Pandora testnet tx succeeded.', + thought: + 'Validate the mainnet draft with the testnet receipt and risk confirmation before dispatching the sign_transaction wallet action.', + tool: 'sign_ixo_transaction', + args: { + command: '/ixo entity transfer', + network: 'mainnet', + value: { + id: 'did:ixo:entity:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ownerDid: 'did:ixo:ixo1qwertyuiopasdfghjklzxcvbnmqwerty12345', + ownerAddress: 'ixo1qwertyuiopasdfghjklzxcvbnmqwerty12345', + recipientDid: 'did:ixo:ixo1zxcvbnmqwertyuiopasdfghjkl1234567890ab', + }, + riskConfirmation: { + confirmed: true, + acceptedRisks: ['Entity ownership will transfer to the recipient.'], + }, + testnetReceipt: { + network: 'testnet', + transactionHash: + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + code: 0, + }, + }, + }, + ], + tags: ['ixo', 'portal', 'transaction', 'wallet', 'cosmos', 'zod'], + category: 'integration', + visibility: 'on-demand', + stability: 'experimental', + }; + + getTools(_ctx: PluginContext): PluginTool[] { + return createIxoTransactionTools(); + } +} + +export function createIxoTransactionPlugin(): IxoTransactionPlugin { + return new IxoTransactionPlugin(); +} diff --git a/packages/ixo-transaction/src/qiforge/tools.ts b/packages/ixo-transaction/src/qiforge/tools.ts new file mode 100644 index 00000000..97cd543e --- /dev/null +++ b/packages/ixo-transaction/src/qiforge/tools.ts @@ -0,0 +1,136 @@ +import { callAgAction } from '@ixo/common/ai/tools/action-caller'; +import { + tool, + z, + type PluginTool, + type RuntimeContext, +} from '@ixo/oracle-runtime/plugin-api'; +import { randomUUID } from 'node:crypto'; + +import { + DEFERRED_MODULES, + MESSAGE_CATALOG, + QUERY_ONLY_MODULES, +} from '../catalog.js'; +import { classifyIntent } from '../intent.js'; +import { + SIGN_TRANSACTION_ACTION_NAME, + SignTransactionActionResultSchema, + buildSignTransactionActionArgs, + normalizeWalletSignResult, +} from '../action.js'; +import { TransactionDraftSchema } from '../schemas.js'; +import { validateTransactionDraft } from '../validate.js'; + +const ACTION_TIMEOUT_MS = 120_000; + +const TransactionDraftToolSchema = TransactionDraftSchema; + +const IntentInputToolSchema = z + .object({ + input: z.string().min(1), + }) + .strict(); + +const RouteListToolSchema = z + .object({ + messageType: z.string().optional(), + }) + .strict(); + +function summarizeRoutes(messageType?: string): unknown { + const normalized = messageType?.trim().toLowerCase(); + const routes = MESSAGE_CATALOG.filter( + (entry) => !normalized || entry.module === normalized, + ).map((entry) => ({ + command: `/ixo ${entry.module} ${entry.action}`, + module: entry.module, + action: entry.action, + messageName: entry.messageName, + typeUrl: entry.typeUrl, + fields: entry.fields, + riskLevel: entry.riskLevel, + risks: entry.risks, + })); + + return { + slashCommandFormat: '/ixo {message-type} {message-action}', + queryOnlyModules: QUERY_ONLY_MODULES, + deferredModules: DEFERRED_MODULES, + routes, + }; +} + +function buildToolCallId(ctx: RuntimeContext): string { + const requestId = ctx.session.requestId ?? 'noreq'; + return `ixo_tx_${requestId}_${randomUUID().slice(0, 8)}`; +} + +async function dispatchSignTransactionAction( + input: unknown, + ctx: RuntimeContext, +): Promise { + const actionArgs = buildSignTransactionActionArgs(input); + const sessionId = ctx.session.id; + if (!sessionId) { + throw new Error('sessionId is required to dispatch wallet signing'); + } + + const result = await callAgAction({ + sessionId, + toolCallId: buildToolCallId(ctx), + toolName: SIGN_TRANSACTION_ACTION_NAME, + args: actionArgs, + timeout: ACTION_TIMEOUT_MS, + }); + + const parsed = SignTransactionActionResultSchema.safeParse(result); + return parsed.success ? parsed.data : normalizeWalletSignResult(result); +} + +export function createIxoTransactionTools(): PluginTool[] { + return [ + tool( + async (args: unknown) => { + const { messageType } = RouteListToolSchema.parse(args); + return summarizeRoutes(messageType); + }, + { + name: 'list_ixo_transaction_routes', + description: + 'List supported IXO transaction routes, required fields, risk levels, and query-only modules.', + schema: RouteListToolSchema, + }, + ), + tool( + async (args: unknown) => { + const { input } = IntentInputToolSchema.parse(args); + return classifyIntent(input); + }, + { + name: 'classify_ixo_transaction_intent', + description: + 'Resolve an IXO transaction intent from a slash command, Msg name, typeUrl, or natural-language prompt.', + schema: IntentInputToolSchema, + }, + ), + tool( + async (args: unknown) => { + const draft = TransactionDraftToolSchema.parse(args); + return validateTransactionDraft(draft); + }, + { + name: 'validate_ixo_transaction_draft', + description: + 'Strictly validate an IXO transaction draft and return the canonical message, risks, network gate status, and resolved intent.', + schema: TransactionDraftToolSchema, + }, + ), + tool(dispatchSignTransactionAction, { + name: 'sign_ixo_transaction', + description: + "Validate an IXO transaction draft, enforce the risk and testnet-first gates, then dispatch it to the user's Portal wallet (the hidden sign_transaction action) and return the signed result — tx hash, or a clear rejection/timeout/error. Default network is testnet; mainnet requires a successful testnet receipt or an explicit recorded override. Requires riskConfirmation for risky transactions. The oracle never signs, broadcasts, or holds keys.", + schema: TransactionDraftToolSchema, + }), + ]; +} diff --git a/packages/ixo-transaction/src/react/index.ts b/packages/ixo-transaction/src/react/index.ts new file mode 100644 index 00000000..f2e4ea16 --- /dev/null +++ b/packages/ixo-transaction/src/react/index.ts @@ -0,0 +1,54 @@ +import { useAgAction, useOraclesContext } from '@ixo/oracles-client-sdk'; +// `@ixo/oracles-client-sdk` builds on Zod 3, while this package validates with +// Zod 4. `useAgAction` types `parameters` against its own Zod 3, so the FE +// action schema is declared with the matching Zod 3 (`zod3`). It is only +// decorative here: the action is hidden (`exposeToAgent: false`) and the handler +// re-validates with the real Zod 4 `SignTransactionActionArgsSchema`. +import { z as zod3 } from 'zod3'; + +import { + SIGN_TRANSACTION_ACTION_DESCRIPTION, + SIGN_TRANSACTION_ACTION_NAME, + signIxoTransactionWithWallet, + type WalletSignTransactionFn, +} from '../action.js'; +import { toEncodeObject } from './proto.js'; + +/** + * Register the hidden `sign_transaction` wallet action in the Portal oracle UI. + * + * `exposeToAgent: false` keeps the raw wallet action OFF the agent's tool list — + * the agent can only reach the wallet through the validated `sign_ixo_transaction` + * Qiforge tool, never by calling `sign_transaction` directly. The handler decodes + * each proto-JSON message into a wallet-ready EncodeObject (via the SDK's + * `fromJSON`) right before signing with `transactSignX`. + */ +export function useIxoTransactionSigningAction(): void { + const { transactSignX } = useOraclesContext(); + + useAgAction({ + name: SIGN_TRANSACTION_ACTION_NAME, + description: SIGN_TRANSACTION_ACTION_DESCRIPTION, + parameters: zod3.unknown(), + exposeToAgent: false, + handler: async (args) => { + const signWithEncoding: WalletSignTransactionFn = (messages, memo) => + transactSignX(messages.map(toEncodeObject), memo); + return signIxoTransactionWithWallet(args, signWithEncoding); + }, + }); +} + +export { resolveProtoCodec, toEncodeObject } from './proto.js'; +export { + SIGN_TRANSACTION_ACTION_DESCRIPTION, + SIGN_TRANSACTION_ACTION_NAME, + SignTransactionActionArgsSchema, + SignTransactionActionResultSchema, + signIxoTransactionWithWallet, +} from '../action.js'; +export type { + SignTransactionActionArgs, + SignTransactionActionResult, + WalletSignTransactionFn, +} from '../action.js'; diff --git a/packages/ixo-transaction/src/react/proto.ts b/packages/ixo-transaction/src/react/proto.ts new file mode 100644 index 00000000..6a483a02 --- /dev/null +++ b/packages/ixo-transaction/src/react/proto.ts @@ -0,0 +1,57 @@ +import { ixo } from '@ixo/impactxclient-sdk'; + +import type { ITrxMsg } from '../schemas.js'; + +/** Cosmos `EncodeObject` — the decoded, wallet-ready message. */ +export interface EncodeObject { + typeUrl: string; + value: unknown; +} + +interface ProtoJsonCodec { + fromJSON(object: unknown): unknown; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function hasFromJSON(value: unknown): value is ProtoJsonCodec { + return isRecord(value) && typeof value.fromJSON === 'function'; +} + +/** + * Resolve the generated protobuf codec for an IXO Msg typeUrl by walking the + * `ixo` namespace — e.g. `/ixo.entity.v1beta1.MsgCreateEntity` resolves to + * `ixo.entity.v1beta1.MsgCreateEntity`. + */ +export function resolveProtoCodec(typeUrl: string): ProtoJsonCodec { + const segments = typeUrl.replace(/^\//, '').split('.'); + if (segments[0] !== 'ixo') { + throw new Error( + `Unsupported typeUrl namespace (expected ixo.*): ${typeUrl}`, + ); + } + let current: unknown = ixo; + for (const segment of segments.slice(1)) { + if (!isRecord(current)) { + throw new Error(`Cannot resolve protobuf codec for ${typeUrl}`); + } + current = current[segment]; + } + if (!hasFromJSON(current)) { + throw new Error(`No fromJSON codec found for ${typeUrl}`); + } + return current; +} + +/** + * Convert a proto-JSON `{ typeUrl, value }` produced by the oracle into a Cosmos + * `EncodeObject` the wallet can sign. The SDK's generated `fromJSON` decodes the + * lossy fields (`bytes` from base64, `Long`, `Timestamp`) into their real + * runtime types so `transactSignX` encodes them correctly. + */ +export function toEncodeObject(message: ITrxMsg): EncodeObject { + const codec = resolveProtoCodec(message.typeUrl); + return { typeUrl: message.typeUrl, value: codec.fromJSON(message.value) }; +} diff --git a/packages/ixo-transaction/src/schemas.ts b/packages/ixo-transaction/src/schemas.ts new file mode 100644 index 00000000..b7e0682d --- /dev/null +++ b/packages/ixo-transaction/src/schemas.ts @@ -0,0 +1,354 @@ +import { z } from 'zod'; + +export const NetworkSchema = z.enum(['devnet', 'testnet', 'mainnet']); +export type Network = z.infer; + +export const IntegerStringSchema = z + .string() + .regex(/^(0|[1-9][0-9]*)$/, 'Use an integer string with no decimal point'); + +export const IxoAddressSchema = z + .string() + .regex( + /^ixo1[0-9a-z]{20,80}$/, + 'Expected an IXO bech32 account address beginning with ixo1', + ); + +export const IxoDidSchema = z + .string() + .regex( + /^did:ixo:(entity:[a-f0-9]{32}|wasm:ixo1[0-9a-z]{20,80}|ixo1[0-9a-z]{20,80}|[A-Za-z0-9:._#-]+)$/, + 'Expected a did:ixo DID', + ); + +export const TimestampSchema = z.union([ + z.string().datetime({ offset: true }), + z + .object({ + seconds: z.union([IntegerStringSchema, z.number().int().nonnegative()]), + nanos: z.number().int().min(0).max(999999999).optional(), + }) + .strict(), +]); + +export const CoinSchema = z + .object({ + denom: z.string().min(1), + amount: IntegerStringSchema, + }) + .strict(); + +export const VerificationMethodSchema = z + .object({ + id: z.string().min(1), + type: z.string().min(1), + controller: IxoDidSchema, + blockchainAccountID: z.string().min(1).optional(), + publicKeyHex: z + .string() + .regex(/^[0-9a-fA-F]+$/) + .optional(), + publicKeyMultibase: z.string().min(1).optional(), + publicKeyBase58: z.string().min(1).optional(), + }) + .strict() + .superRefine((value, ctx) => { + const materialFields = [ + 'blockchainAccountID', + 'publicKeyHex', + 'publicKeyMultibase', + 'publicKeyBase58', + ] as const; + const present = materialFields.filter( + (field) => value[field] !== undefined, + ); + if (present.length !== 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'VerificationMethod must set exactly one verification material field', + }); + } + }); + +export const VerificationSchema = z + .object({ + relationships: z.array(z.string().min(1)).min(1), + method: VerificationMethodSchema, + context: z.array(z.string().min(1)).optional(), + }) + .strict(); + +export const ServiceSchema = z + .object({ + id: z.string().min(1), + type: z.string().min(1), + serviceEndpoint: z.string().min(1), + }) + .strict(); + +export const ContextSchema = z + .object({ + key: z.string().min(1), + val: z.string().min(1), + }) + .strict(); + +export const LinkedResourceSchema = z + .object({ + id: z.string().min(1), + type: z.string().min(1), + description: z.string().optional(), + mediaType: z.string().optional(), + serviceEndpoint: z.string().min(1), + proof: z.string().optional(), + encrypted: z.string().optional(), + right: z.string().optional(), + }) + .strict(); + +export const LinkedEntitySchema = z + .object({ + id: IxoDidSchema, + type: z.string().min(1), + relationship: z.string().min(1), + service: z.string().optional(), + }) + .strict(); + +export const LinkedClaimSchema = z + .object({ + id: z.string().min(1), + type: z.string().min(1), + description: z.string().optional(), + serviceEndpoint: z.string().optional(), + proof: z.string().optional(), + encrypted: z.string().optional(), + right: z.string().optional(), + }) + .strict(); + +export const AccordedRightSchema = z + .object({ + id: z.string().min(1), + type: z.string().min(1), + mechanism: z.string().optional(), + message: z.string().optional(), + service: z.string().optional(), + }) + .strict(); + +export const AnySchema = z + .object({ + typeUrl: z + .string() + .regex(/^\/[A-Za-z0-9.]+$/, 'Expected a protobuf Any typeUrl'), + value: z.unknown(), + }) + .strict(); + +export const AuthzGrantSchema = z + .object({ + authorization: AnySchema, + expiration: TimestampSchema.optional(), + }) + .strict(); + +export const TokenBatchSchema = z + .object({ + id: z.string().min(1), + amount: IntegerStringSchema, + }) + .strict(); + +/** + * Canonical Cosmos `EncodeObject` — `{ typeUrl, value }`. The proto-JSON `value` + * is what crosses to the frontend; the Portal `sign_transaction` handler runs it + * through the IXO SDK's `fromJSON` before signing. + */ +export const ITrxMsgSchema = z + .object({ + typeUrl: z + .string() + .regex( + /^\/[A-Za-z0-9.]+\.Msg[A-Za-z0-9]+$/, + 'Expected an IXO/Cosmos Msg typeUrl', + ), + value: z.record(z.string(), z.unknown()), + }) + .strict(); + +export type ITrxMsg = z.infer; + +export const RiskConfirmationSchema = z + .object({ + confirmed: z.literal(true), + acceptedRisks: z.array(z.string().min(1)).min(1), + }) + .strict(); + +export type RiskConfirmation = z.infer; + +export const TestnetReceiptSchema = z + .object({ + network: z.literal('testnet'), + transactionHash: z.string().regex(/^[0-9A-Fa-f]{32,128}$/), + code: z.literal(0), + height: z + .union([IntegerStringSchema, z.number().int().nonnegative()]) + .optional(), + }) + .strict(); + +export type TestnetReceipt = z.infer; + +export const TransactionDraftSchema = z + .object({ + input: z.string().optional(), + command: z.string().optional(), + messageType: z.string().optional(), + action: z.string().optional(), + typeUrl: z.string().optional(), + value: z.record(z.string(), z.unknown()).default({}), + memo: z.string().optional(), + network: NetworkSchema.default('testnet'), + riskConfirmation: RiskConfirmationSchema.optional(), + testnetReceipt: TestnetReceiptSchema.optional(), + overrideMainnet: z.boolean().optional(), + overrideReason: z.string().min(1).optional(), + }) + .strict() + .superRefine((value, ctx) => { + if ( + !value.input && + !value.command && + (!value.messageType || !value.action) && + !value.typeUrl + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Provide input, command, messageType/action, or typeUrl', + }); + } + if (value.overrideMainnet && !value.overrideReason) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'overrideReason is required when overrideMainnet is true', + }); + } + }); + +export type TransactionDraft = z.infer; + +/** + * Field kinds reference the actual protobuf field types of the supported + * IXO `Msg`s (verified against `@ixo/impactxclient-sdk`). Singular `*` kinds + * map to a single nested message; `*Array` kinds map to a repeated field. + * Deeply-nested protobuf structures we do not model field-by-field use + * `json`/`jsonArray` (validated as present, encoded by the frontend via the + * SDK's `fromJSON`). + */ +export type FieldKind = + | 'string' + | 'stringArray' + | 'did' + | 'didArray' + | 'address' + | 'bool' + | 'int' + | 'uint' + | 'integerString' + | 'timestamp' + | 'coin' + | 'coinArray' + | 'json' + | 'jsonArray' + | 'bytes' + | 'verification' + | 'verificationArray' + | 'service' + | 'serviceArray' + | 'context' + | 'contextArray' + | 'linkedResource' + | 'linkedResourceArray' + | 'linkedEntity' + | 'linkedEntityArray' + | 'linkedClaim' + | 'linkedClaimArray' + | 'accordedRight' + | 'accordedRightArray' + | 'tokenBatchArray' + | 'authzGrant'; + +export function schemaForFieldKind(kind: FieldKind): z.ZodTypeAny { + switch (kind) { + case 'string': + return z.string().min(1); + case 'stringArray': + return z.array(z.string().min(1)); + case 'did': + return IxoDidSchema; + case 'didArray': + return z.array(IxoDidSchema); + case 'address': + return IxoAddressSchema; + case 'bool': + return z.boolean(); + case 'int': + return z.number().int(); + case 'uint': + return z.union([IntegerStringSchema, z.number().int().nonnegative()]); + case 'integerString': + return IntegerStringSchema; + case 'timestamp': + return TimestampSchema; + case 'coin': + return CoinSchema; + case 'coinArray': + return z.array(CoinSchema); + case 'bytes': + return z.union([ + z.string().min(1), + z.array(z.number().int().min(0).max(255)), + ]); + case 'verification': + return VerificationSchema; + case 'verificationArray': + return z.array(VerificationSchema).min(1); + case 'service': + return ServiceSchema; + case 'serviceArray': + return z.array(ServiceSchema); + case 'context': + return ContextSchema; + case 'contextArray': + return z.array(ContextSchema); + case 'linkedResource': + return LinkedResourceSchema; + case 'linkedResourceArray': + return z.array(LinkedResourceSchema); + case 'linkedEntity': + return LinkedEntitySchema; + case 'linkedEntityArray': + return z.array(LinkedEntitySchema); + case 'linkedClaim': + return LinkedClaimSchema; + case 'linkedClaimArray': + return z.array(LinkedClaimSchema); + case 'accordedRight': + return AccordedRightSchema; + case 'accordedRightArray': + return z.array(AccordedRightSchema); + case 'tokenBatchArray': + return z.array(TokenBatchSchema).min(1); + case 'authzGrant': + return AuthzGrantSchema; + case 'json': + return z.record(z.string(), z.unknown()); + case 'jsonArray': + return z.array(z.record(z.string(), z.unknown())); + default: + return z.never(); + } +} diff --git a/packages/ixo-transaction/src/validate.ts b/packages/ixo-transaction/src/validate.ts new file mode 100644 index 00000000..5dd6c93a --- /dev/null +++ b/packages/ixo-transaction/src/validate.ts @@ -0,0 +1,107 @@ +import { z } from 'zod'; + +import { + findMessageByTypeUrl, + type FieldSpec, + type MessageSpec, +} from './catalog.js'; +import { resolveIntent, type IntentResult } from './intent.js'; +import { + ITrxMsgSchema, + TransactionDraftSchema, + schemaForFieldKind, + type ITrxMsg, + type TransactionDraft, +} from './schemas.js'; + +export type ValidatedTransaction = { + intent: IntentResult; + message: ITrxMsg; + risks: string[]; + riskLevel: MessageSpec['riskLevel']; + requiresConfirmation: boolean; + network: TransactionDraft['network']; + memo?: string; +}; + +export type ValidationOptions = { + requireRiskConfirmation?: boolean; +}; + +function buildValueSchema( + fields: readonly FieldSpec[], +): z.ZodObject> { + const shape: Record = {}; + for (const field of fields) { + const schema = schemaForFieldKind(field.kind); + shape[field.name] = field.required ? schema : schema.optional(); + } + return z.object(shape).strict(); +} + +function assertTypeUrlConflict( + draftTypeUrl: string | undefined, + resolvedTypeUrl: string, +): void { + if (draftTypeUrl && draftTypeUrl !== resolvedTypeUrl) { + throw new Error( + `typeUrl conflict: draft provided ${draftTypeUrl}, but intent resolves to ${resolvedTypeUrl}`, + ); + } +} + +function assertMainnetGate(draft: TransactionDraft): void { + if (draft.network !== 'mainnet') return; + if (draft.testnetReceipt || draft.overrideMainnet === true) return; + throw new Error( + 'Mainnet draft blocked: provide a successful Pandora testnet receipt or set overrideMainnet with overrideReason', + ); +} + +function isRisky(spec: MessageSpec): boolean { + return spec.riskLevel !== 'low' || spec.risks.length > 0; +} + +function assertRiskGate( + spec: MessageSpec, + draft: TransactionDraft, + options: ValidationOptions, +): void { + if (!isRisky(spec) || !options.requireRiskConfirmation) return; + if (draft.riskConfirmation?.confirmed === true) return; + throw new Error( + `Risk confirmation required before signing ${spec.messageName}`, + ); +} + +/** + * Resolve, strictly validate, and canonicalize a transaction draft into a + * Cosmos `EncodeObject` ready for the frontend wallet. With + * `requireRiskConfirmation`, also enforces the risk and mainnet gates. + */ +export function validateTransactionDraft( + input: unknown, + options: ValidationOptions = {}, +): ValidatedTransaction { + const draft = TransactionDraftSchema.parse(input); + const intent = resolveIntent(draft); + const spec = findMessageByTypeUrl(intent.typeUrl); + if (!spec) throw new Error(`Resolved unsupported typeUrl: ${intent.typeUrl}`); + + assertTypeUrlConflict(draft.typeUrl, intent.typeUrl); + assertMainnetGate(draft); + assertRiskGate(spec, draft, options); + + const value = buildValueSchema(spec.fields).parse(draft.value); + const message = ITrxMsgSchema.parse({ typeUrl: spec.typeUrl, value }); + + return { + intent, + message, + risks: spec.risks, + riskLevel: spec.riskLevel, + requiresConfirmation: isRisky(spec), + network: draft.network, + memo: draft.memo, + }; +} diff --git a/packages/ixo-transaction/tests/fixtures.ts b/packages/ixo-transaction/tests/fixtures.ts new file mode 100644 index 00000000..9f08e137 --- /dev/null +++ b/packages/ixo-transaction/tests/fixtures.ts @@ -0,0 +1,38 @@ +import type { TransactionDraft } from '../src/schemas.js'; + +export const ADDRESS = 'ixo1qwertyuiopasdfghjklzxcvbnmqwerty12345'; +export const ADDRESS_2 = 'ixo1zxcvbnmqwertyuiopasdfghjkl1234567890ab'; +export const DID = `did:ixo:${ADDRESS}`; +export const DID_2 = `did:ixo:${ADDRESS_2}`; +export const ENTITY_DID = 'did:ixo:entity:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + +export const verification = [ + { + relationships: ['authentication'], + method: { + id: `${DID}#key-1`, + type: 'EcdsaSecp256k1VerificationKey2019', + controller: DID, + blockchainAccountID: ADDRESS, + }, + }, +]; + +export const riskConfirmation = { + confirmed: true as const, + acceptedRisks: ['User confirmed transaction risks before signing.'], +}; + +export function draft( + command: string, + value: Record, + extra: Partial = {}, +): TransactionDraft { + return { + command, + value, + network: 'testnet', + riskConfirmation, + ...extra, + }; +} diff --git a/packages/ixo-transaction/tests/intent.test.ts b/packages/ixo-transaction/tests/intent.test.ts new file mode 100644 index 00000000..393e6260 --- /dev/null +++ b/packages/ixo-transaction/tests/intent.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { classifyIntent, parseSlashCommand } from '../src/intent.js'; + +describe('intent routing', () => { + it.each([ + ['/ixo entity create', 'MsgCreateEntity'], + ['/ixo entity transfer', 'MsgTransferEntity'], + ['/ixo iid add-linked-resource', 'MsgAddLinkedResource'], + ['/ixo claims submit', 'MsgSubmitClaim'], + ['/ixo token retire', 'MsgRetireToken'], + ['/ixo smart-account add-authenticator', 'MsgAddAuthenticator'], + ])('routes %s', (command, messageName) => { + expect(parseSlashCommand(command).messageName).toBe(messageName); + }); + + it('normalizes natural language for creating a domain', () => { + expect(classifyIntent('I want to create a new domain').messageName).toBe( + 'MsgCreateEntity', + ); + }); + + it('normalizes create entity typos', () => { + expect(classifyIntent('megCreateEntity').messageName).toBe( + 'MsgCreateEntity', + ); + expect(classifyIntent('msgCreateEntity').messageName).toBe( + 'MsgCreateEntity', + ); + }); + + it('rejects malformed slash commands', () => { + expect(() => parseSlashCommand('/ixo entity')).toThrow(/Slash command/); + }); +}); diff --git a/packages/ixo-transaction/tests/proto.test.ts b/packages/ixo-transaction/tests/proto.test.ts new file mode 100644 index 00000000..ac51cf4a --- /dev/null +++ b/packages/ixo-transaction/tests/proto.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveProtoCodec, toEncodeObject } from '../src/react/proto.js'; +import { ADDRESS, DID, DID_2, ENTITY_DID } from './fixtures.js'; + +describe('proto fromJSON conversion (BE proto-JSON -> wallet EncodeObject)', () => { + it('resolves a generated codec for an ixo Msg typeUrl', () => { + const codec = resolveProtoCodec('/ixo.entity.v1beta1.MsgTransferEntity'); + expect(typeof codec.fromJSON).toBe('function'); + }); + + it('keeps the typeUrl and converts a plain message', () => { + const enc = toEncodeObject({ + typeUrl: '/ixo.entity.v1beta1.MsgTransferEntity', + value: { + id: ENTITY_DID, + ownerDid: DID, + ownerAddress: ADDRESS, + recipientDid: DID_2, + }, + }); + expect(enc.typeUrl).toBe('/ixo.entity.v1beta1.MsgTransferEntity'); + expect(enc.value).toMatchObject({ id: ENTITY_DID, recipientDid: DID_2 }); + }); + + it('decodes a base64 bytes field into a Uint8Array (the lossy case)', () => { + const enc = toEncodeObject({ + typeUrl: '/ixo.smartaccount.v1beta1.MsgAddAuthenticator', + value: { + sender: ADDRESS, + authenticatorType: 'SignatureVerification', + // base64 of [1, 2, 3] + data: 'AQID', + }, + }); + expect(enc.value).toMatchObject({ + sender: ADDRESS, + data: expect.any(Uint8Array), + }); + }); + + it('rejects a non-ixo namespace', () => { + expect(() => resolveProtoCodec('/cosmos.bank.v1beta1.MsgSend')).toThrow( + /namespace/, + ); + }); + + it('rejects an unknown ixo Msg', () => { + expect(() => resolveProtoCodec('/ixo.entity.v1beta1.MsgNotReal')).toThrow( + /codec/, + ); + }); +}); diff --git a/packages/ixo-transaction/tests/qiforge-plugin.test.ts b/packages/ixo-transaction/tests/qiforge-plugin.test.ts new file mode 100644 index 00000000..87d26be1 --- /dev/null +++ b/packages/ixo-transaction/tests/qiforge-plugin.test.ts @@ -0,0 +1,103 @@ +import { callAgAction } from '@ixo/common/ai/tools/action-caller'; +import { describe, expect, it, vi } from 'vitest'; + +import { ADDRESS, DID, DID_2, ENTITY_DID, draft } from './fixtures.js'; + +vi.mock('@ixo/common/ai/tools/action-caller', () => ({ + callAgAction: vi.fn(), +})); + +async function loadPluginModule() { + return import('../src/qiforge/index.js'); +} + +describe('Qiforge plugin', () => { + it('exposes a Qiforge OraclePlugin manifest and tools', async () => { + const { IxoTransactionPlugin, createIxoTransactionPlugin } = + await loadPluginModule(); + const plugin = createIxoTransactionPlugin(); + const tools = await plugin.getTools({} as never); + + expect(plugin).toBeInstanceOf(IxoTransactionPlugin); + expect(plugin.name).toBe('ixo-transaction'); + expect(plugin.manifest.visibility).toBe('on-demand'); + expect(tools.map((entry) => entry.name)).toEqual([ + 'list_ixo_transaction_routes', + 'classify_ixo_transaction_intent', + 'validate_ixo_transaction_draft', + 'sign_ixo_transaction', + ]); + }); + + it('classifies natural language intent through a plugin tool', async () => { + const { IxoTransactionPlugin } = await loadPluginModule(); + const plugin = new IxoTransactionPlugin(); + const tools = await plugin.getTools({} as never); + const classify = tools.find( + (entry) => entry.name === 'classify_ixo_transaction_intent', + ); + + const result = await classify?.handler( + { input: 'I want to create a new domain' }, + {} as never, + ); + + expect(result).toMatchObject({ + module: 'entity', + action: 'create', + messageName: 'MsgCreateEntity', + typeUrl: '/ixo.entity.v1beta1.MsgCreateEntity', + }); + }); + + it('dispatches the validated draft to the frontend sign_transaction action', async () => { + vi.mocked(callAgAction).mockResolvedValueOnce({ + success: true, + transactionHash: 'A'.repeat(64), + }); + const { IxoTransactionPlugin } = await loadPluginModule(); + const plugin = new IxoTransactionPlugin(); + const tools = await plugin.getTools({} as never); + const sign = tools.find((entry) => entry.name === 'sign_ixo_transaction'); + + const result = await sign?.handler( + draft('/ixo entity transfer', { + id: ENTITY_DID, + ownerDid: DID, + ownerAddress: ADDRESS, + recipientDid: DID_2, + }), + { session: { id: 'session-1', requestId: 'req-1' } } as never, + ); + + expect(result).toMatchObject({ + success: true, + transactionHash: 'A'.repeat(64), + }); + expect(callAgAction).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'session-1', + toolName: 'sign_transaction', + timeout: 120000, + args: expect.objectContaining({ + action: 'sign_transaction', + network: 'testnet', + riskLevel: 'critical', + requiresConfirmation: true, + riskConfirmation: expect.objectContaining({ confirmed: true }), + messages: [ + { + typeUrl: '/ixo.entity.v1beta1.MsgTransferEntity', + value: expect.objectContaining({ + id: ENTITY_DID, + ownerDid: DID, + ownerAddress: ADDRESS, + recipientDid: DID_2, + }), + }, + ], + }), + }), + ); + }); +}); diff --git a/packages/ixo-transaction/tests/validate-render.test.ts b/packages/ixo-transaction/tests/validate-render.test.ts new file mode 100644 index 00000000..b72799ae --- /dev/null +++ b/packages/ixo-transaction/tests/validate-render.test.ts @@ -0,0 +1,315 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + buildSignTransactionActionArgs, + signIxoTransactionWithWallet, +} from '../src/action.js'; +import { validateTransactionDraft } from '../src/validate.js'; +import { + ADDRESS, + ADDRESS_2, + DID, + DID_2, + ENTITY_DID, + draft, + riskConfirmation, + verification, +} from './fixtures.js'; + +describe('validation and signing action args', () => { + it('builds MsgCreateEntity as validated sign_transaction action args', () => { + const actionArgs = buildSignTransactionActionArgs( + draft('/ixo entity create', { + entityType: 'protocol', + verification, + ownerDid: DID, + ownerAddress: ADDRESS, + relayerNode: ENTITY_DID, + controller: [DID], + }), + ); + + expect(actionArgs).toEqual({ + action: 'sign_transaction', + network: 'testnet', + messages: [ + { + typeUrl: '/ixo.entity.v1beta1.MsgCreateEntity', + value: { + entityType: 'protocol', + verification, + ownerDid: DID, + ownerAddress: ADDRESS, + relayerNode: ENTITY_DID, + controller: [DID], + }, + }, + ], + intent: { + source: 'slash-command', + module: 'entity', + action: 'create', + messageName: 'MsgCreateEntity', + typeUrl: '/ixo.entity.v1beta1.MsgCreateEntity', + confidence: 1, + ambiguities: [], + }, + risks: [ + 'Creates a new entity, admin account, DID document, and ownership NFT. The entity DID is chain-derived and cannot be chosen.', + ], + riskLevel: 'high', + requiresConfirmation: true, + riskConfirmation, + }); + }); + + it.each([ + draft('/ixo entity transfer', { + id: ENTITY_DID, + ownerDid: DID, + ownerAddress: ADDRESS, + recipientDid: DID_2, + }), + draft('/ixo iid add-linked-resource', { + id: ENTITY_DID, + signer: ADDRESS, + linkedResource: { + id: '{id}#pro', + type: 'Settings', + serviceEndpoint: 'https://cellnode.example/profile.json', + }, + }), + draft('/ixo claims submit', { + collectionId: 'collection-1', + claimId: 'claim-1', + agentAddress: ADDRESS, + agentDid: DID, + adminAddress: ADDRESS_2, + }), + draft('/ixo token retire', { + owner: ADDRESS, + tokens: [{ id: 'CREDIT-1', amount: '10' }], + jurisdiction: 'Global', + reason: 'offset', + }), + draft('/ixo smart-account add-authenticator', { + sender: ADDRESS, + authenticatorType: 'SignatureVerification', + data: '0x1234', + }), + ])('validates positive fixture %#', (fixture) => { + expect(validateTransactionDraft(fixture).message.typeUrl).toMatch( + /^\/ixo\./, + ); + }); + + it('rejects conflicting typeUrl', () => { + expect(() => + validateTransactionDraft( + draft( + '/ixo token retire', + { + owner: ADDRESS, + tokens: [{ id: 'CREDIT-1', amount: '10' }], + jurisdiction: 'Global', + reason: 'offset', + }, + { typeUrl: '/ixo.entity.v1beta1.MsgCreateEntity' }, + ), + ), + ).toThrow(/typeUrl conflict/); + }); + + it('rejects missing required fields', () => { + expect(() => + validateTransactionDraft( + draft('/ixo entity transfer', { + id: ENTITY_DID, + ownerDid: DID, + ownerAddress: ADDRESS, + }), + ), + ).toThrow(); + }); + + it('rejects invalid DID and address values', () => { + expect(() => + validateTransactionDraft( + draft('/ixo entity transfer', { + id: 'not-a-did', + ownerDid: DID, + ownerAddress: ADDRESS, + recipientDid: DID_2, + }), + ), + ).toThrow(); + expect(() => + validateTransactionDraft( + draft('/ixo entity transfer', { + id: ENTITY_DID, + ownerDid: DID, + ownerAddress: 'cosmos1bad', + recipientDid: DID_2, + }), + ), + ).toThrow(); + }); + + it('rejects decimal token amounts', () => { + expect(() => + validateTransactionDraft( + draft('/ixo token retire', { + owner: ADDRESS, + tokens: [{ id: 'CREDIT-1', amount: '1.5' }], + jurisdiction: 'Global', + reason: 'offset', + }), + ), + ).toThrow(); + }); + + it('rejects invalid timestamps', () => { + expect(() => + validateTransactionDraft( + draft('/ixo entity update', { + id: ENTITY_DID, + controllerDid: DID, + controllerAddress: ADDRESS, + startDate: 'tomorrow', + }), + ), + ).toThrow(); + }); + + it('rejects invalid verification material oneofs', () => { + const badVerification = [ + { + relationships: ['authentication'], + method: { + id: `${DID}#key-1`, + type: 'EcdsaSecp256k1VerificationKey2019', + controller: DID, + blockchainAccountID: ADDRESS, + publicKeyHex: 'abcdef', + }, + }, + ]; + expect(() => + validateTransactionDraft( + draft('/ixo entity create', { + entityType: 'protocol', + verification: badVerification, + ownerDid: DID, + ownerAddress: ADDRESS, + relayerNode: ENTITY_DID, + }), + ), + ).toThrow(); + }); + + it('rejects unknown fields', () => { + expect(() => + validateTransactionDraft( + draft('/ixo entity transfer', { + id: ENTITY_DID, + ownerDid: DID, + ownerAddress: ADDRESS, + recipientDid: DID_2, + extraField: true, + }), + ), + ).toThrow(); + }); + + it('blocks mainnet without testnet receipt or explicit override', () => { + expect(() => + validateTransactionDraft( + draft( + '/ixo entity transfer', + { + id: ENTITY_DID, + ownerDid: DID, + ownerAddress: ADDRESS, + recipientDid: DID_2, + }, + { network: 'mainnet', riskConfirmation }, + ), + ), + ).toThrow(/Mainnet draft blocked/); + }); + + it('allows mainnet with successful testnet receipt', () => { + const validated = validateTransactionDraft( + draft( + '/ixo entity transfer', + { + id: ENTITY_DID, + ownerDid: DID, + ownerAddress: ADDRESS, + recipientDid: DID_2, + }, + { + network: 'mainnet', + testnetReceipt: { + network: 'testnet', + transactionHash: 'A'.repeat(64), + code: 0, + }, + }, + ), + ); + expect(validated.network).toBe('mainnet'); + }); + + it('calls transactSignX with validated action messages and memo', async () => { + const actionArgs = buildSignTransactionActionArgs( + draft( + '/ixo token retire', + { + owner: ADDRESS, + tokens: [{ id: 'CREDIT-1', amount: '10' }], + jurisdiction: 'Global', + reason: 'offset', + }, + { memo: 'retire credits' }, + ), + ); + const transactSignX = vi.fn().mockResolvedValue({ + transactionHash: 'B'.repeat(64), + code: 0, + height: 123, + }); + + const result = await signIxoTransactionWithWallet( + actionArgs, + transactSignX, + ); + + expect(transactSignX).toHaveBeenCalledWith( + actionArgs.messages, + 'retire credits', + ); + expect(result).toMatchObject({ + success: true, + transactionHash: 'B'.repeat(64), + code: 0, + height: 123, + }); + }); + + it('requires risk confirmation before signing action dispatch', () => { + const noConfirmation = { + command: '/ixo entity transfer', + network: 'testnet', + value: { + id: ENTITY_DID, + ownerDid: DID, + ownerAddress: ADDRESS, + recipientDid: DID_2, + }, + }; + expect(() => buildSignTransactionActionArgs(noConfirmation)).toThrow( + /Risk confirmation required/, + ); + }); +}); diff --git a/packages/ixo-transaction/tsconfig.build.json b/packages/ixo-transaction/tsconfig.build.json new file mode 100644 index 00000000..85c5ad1a --- /dev/null +++ b/packages/ixo-transaction/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules", "tests", "vitest.config.ts"] +} diff --git a/packages/ixo-transaction/tsconfig.json b/packages/ixo-transaction/tsconfig.json new file mode 100644 index 00000000..ef013b36 --- /dev/null +++ b/packages/ixo-transaction/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@ixo/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": ".", + "types": ["node", "vitest/globals"], + "noEmit": true + }, + "include": ["src", "scripts", "tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ixo-transaction/vitest.config.ts b/packages/ixo-transaction/vitest.config.ts new file mode 100644 index 00000000..cc80be44 --- /dev/null +++ b/packages/ixo-transaction/vitest.config.ts @@ -0,0 +1,22 @@ +import baseConfig, { defineConfig, mergeConfig } from '@ixo/vitest-config/base'; + +export default mergeConfig( + baseConfig, + defineConfig({ + resolve: { + alias: { + '@ixo/oracle-runtime/plugin-api': new URL( + '../oracle-runtime/src/plugin-api/index.ts', + import.meta.url, + ).pathname, + '@ixo/common/ai/tools/action-caller': new URL( + '../common/src/ai/tools/action-caller.ts', + import.meta.url, + ).pathname, + }, + }, + test: { + include: ['tests/**/*.test.ts'], + }, + }), +); diff --git a/packages/oracle-runtime/package.json b/packages/oracle-runtime/package.json index ecefe9b3..c95a2d62 100644 --- a/packages/oracle-runtime/package.json +++ b/packages/oracle-runtime/package.json @@ -31,6 +31,11 @@ "import": "./dist/index.js", "default": "./dist/index.js" }, + "./plugin-api": { + "types": "./dist/plugin-api/index.d.ts", + "import": "./dist/plugin-api/index.js", + "default": "./dist/plugin-api/index.js" + }, "./testing": { "types": "./dist/testing/index.d.ts", "import": "./dist/testing/index.js", diff --git a/packages/oracle-runtime/src/plugin-api/index.ts b/packages/oracle-runtime/src/plugin-api/index.ts new file mode 100644 index 00000000..d78bd32a --- /dev/null +++ b/packages/oracle-runtime/src/plugin-api/index.ts @@ -0,0 +1,29 @@ +export { OraclePlugin } from './oracle-plugin.js'; +export { defineOraclePlugin } from './define-plugin.js'; +export { tool } from './tool-helper.js'; + +export type { + AuthExcludedRoute, + ChatOpenAIFields, + Logger, + ManifestExample, + MergedConfig, + MatrixEvent, + ModelRole, + OracleConfig, + OracleIdentity, + OraclePromptConfig, + PluginContext, + PluginManifest, + PluginSubAgent, + PluginTool, + ReadonlyState, + RoomStateSnapshot, + RuntimeContext, + SecretIndex, + SharedAccessors, + UcanDelegation, + UserContextData, +} from './types.js'; + +export { z } from 'zod'; diff --git a/packages/oracles-client-sdk/src/hooks/use-ag-action.ts b/packages/oracles-client-sdk/src/hooks/use-ag-action.ts index dfbd1224..518c4b80 100644 --- a/packages/oracles-client-sdk/src/hooks/use-ag-action.ts +++ b/packages/oracles-client-sdk/src/hooks/use-ag-action.ts @@ -7,6 +7,7 @@ export interface AgActionConfig { description: string; parameters: TSchema; handler: (args: z.infer) => Promise | unknown; + exposeToAgent?: boolean; render?: (props: { status?: 'isRunning' | 'done'; args?: z.infer; @@ -20,6 +21,7 @@ export interface AgAction { description: string; parameters: z.ZodTypeAny; hasRender: boolean; + exposeToAgent: boolean; } /** @@ -61,6 +63,7 @@ export function useAgAction( description: config.description, parameters: config.parameters, hasRender: !!config.render, + exposeToAgent: config.exposeToAgent ?? true, }; registerAgAction( diff --git a/packages/oracles-client-sdk/src/hooks/use-chat/v2/use-chat.ts b/packages/oracles-client-sdk/src/hooks/use-chat/v2/use-chat.ts index 64abfade..79f7f4f6 100644 --- a/packages/oracles-client-sdk/src/hooks/use-chat/v2/use-chat.ts +++ b/packages/oracles-client-sdk/src/hooks/use-chat/v2/use-chat.ts @@ -84,8 +84,13 @@ export function useChat({ oracleDid, overrides, ); - const { authedRequest, executeAgAction, getAgActionRender, agActions } = - useOraclesContext(); + const { + authedRequest, + executeAgAction, + getAgActionRender, + agActions, + registeredAgActions, + } = useOraclesContext(); const apiUrl = overrides?.baseUrl ?? config.apiUrl; // React Query for initial data fetch @@ -321,7 +326,7 @@ export function useChat({ // Build actionTools from registered AG-UI actions const actionTools = useMemo(() => { const tools: IActionTools = {}; - agActions.forEach((action) => { + registeredAgActions.forEach((action) => { tools[action.name] = { toolName: action.name, description: action.description, @@ -333,7 +338,7 @@ export function useChat({ }; }); return tools; - }, [agActions, executeAgAction, getAgActionRender]); + }, [registeredAgActions, executeAgAction, getAgActionRender]); const { isConnected: isWebSocketConnected } = useWebSocketEvents({ oracleDid, diff --git a/packages/oracles-client-sdk/src/providers/oracles-provider/oracles-context.tsx b/packages/oracles-client-sdk/src/providers/oracles-provider/oracles-context.tsx index ed20fad5..5d92cf00 100644 --- a/packages/oracles-client-sdk/src/providers/oracles-provider/oracles-context.tsx +++ b/packages/oracles-client-sdk/src/providers/oracles-provider/oracles-context.tsx @@ -50,6 +50,9 @@ export const OraclesProvider = ({ // AG-UI action state management const [agActions, setAgActions] = useState([]); + const [registeredAgActions, setRegisteredAgActions] = useState( + [], + ); const agActionHandlers = useRef< Map Promise | unknown> >(new Map()); @@ -148,14 +151,22 @@ export const OraclesProvider = ({ handler: (args: unknown) => Promise | unknown, render?: (props: Record) => React.ReactElement | null, ) => { + setRegisteredAgActions((prev) => { + const exists = prev.some((a) => a.name === action.name); + if (exists) { + return prev.map((a) => (a.name === action.name ? action : a)); + } + return [...prev, action]; + }); + setAgActions((prev) => { - // Check if action already exists + if (action.exposeToAgent === false) { + return prev.filter((a) => a.name !== action.name); + } const exists = prev.some((a) => a.name === action.name); if (exists) { - // Update existing action return prev.map((a) => (a.name === action.name ? action : a)); } - // Add new action return [...prev, action]; }); @@ -169,6 +180,7 @@ export const OraclesProvider = ({ const unregisterAgAction = useCallback((name: string) => { setAgActions((prev) => prev.filter((a) => a.name !== name)); + setRegisteredAgActions((prev) => prev.filter((a) => a.name !== name)); agActionHandlers.current.delete(name); agActionRenders.current.delete(name); }, []); @@ -198,6 +210,7 @@ export const OraclesProvider = ({ getDelegation, getInvocation, agActions, + registeredAgActions, registerAgAction, unregisterAgAction, executeAgAction, @@ -210,6 +223,7 @@ export const OraclesProvider = ({ getDelegation, getInvocation, agActions, + registeredAgActions, registerAgAction, unregisterAgAction, executeAgAction, diff --git a/packages/oracles-client-sdk/src/providers/oracles-provider/types.ts b/packages/oracles-client-sdk/src/providers/oracles-provider/types.ts index 831d2f37..482151cb 100644 --- a/packages/oracles-client-sdk/src/providers/oracles-provider/types.ts +++ b/packages/oracles-client-sdk/src/providers/oracles-provider/types.ts @@ -43,6 +43,7 @@ export interface IOraclesContextProps { getInvocation: (oracleDid: string) => Promise; // AG-UI action management agActions: AgAction[]; + registeredAgActions: AgAction[]; registerAgAction: ( action: AgAction, handler: (args: unknown) => Promise | unknown, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec75bc67..30521d59 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -538,6 +538,46 @@ importers: specifier: ^5.3.3 version: 5.9.3 + packages/ixo-transaction: + dependencies: + '@ixo/common': + specifier: workspace:* + version: link:../common + zod: + specifier: ^4.4.3 + version: 4.4.3 + zod3: + specifier: npm:zod@^3.25.0 + version: zod@3.25.76 + devDependencies: + '@ixo/impactxclient-sdk': + specifier: ^2.4.1 + version: 2.4.1 + '@ixo/oracle-runtime': + specifier: workspace:* + version: link:../oracle-runtime + '@ixo/oracles-client-sdk': + specifier: workspace:* + version: link:../oracles-client-sdk + '@ixo/typescript-config': + specifier: workspace:* + version: link:../typescript-config + '@ixo/vitest-config': + specifier: workspace:* + version: link:../vitest-config + '@types/node': + specifier: ^22.10.5 + version: 22.19.19 + tsx: + specifier: ^4.20.3 + version: 4.22.4 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.6(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + packages/logger: dependencies: winston: @@ -5853,7 +5893,7 @@ packages: deprecated: 'replaced by the ''expo'' package, learn more: https://blog.expo.dev/whats-new-in-expo-modules-infrastructure-7a7cdda81ebc' '@veramo-community/lds-ecdsa-secp256k1-recovery2020@https://codeload.github.com/uport-project/EcdsaSecp256k1RecoverySignature2020/tar.gz/ab0db52de6f4e6663ef271a48009ba26e688ef9b': - resolution: {gitHosted: true, tarball: https://codeload.github.com/uport-project/EcdsaSecp256k1RecoverySignature2020/tar.gz/ab0db52de6f4e6663ef271a48009ba26e688ef9b} + resolution: {gitHosted: true, integrity: sha512-jJP+rCoHQRM4RVYeyCfc52aDZ4lUluAJ+HkNi7kCTLsZ5uYfE9uR+wBYw4uV02GooTfWPeCNPrFxXZpfhI4TyQ==, tarball: https://codeload.github.com/uport-project/EcdsaSecp256k1RecoverySignature2020/tar.gz/ab0db52de6f4e6663ef271a48009ba26e688ef9b} version: 0.0.8 '@veramo/core-types@5.6.0': diff --git a/specs/ixo-transaction-signing-plugin.md b/specs/ixo-transaction-signing-plugin.md new file mode 100644 index 00000000..a0992447 --- /dev/null +++ b/specs/ixo-transaction-signing-plugin.md @@ -0,0 +1,208 @@ +# IXO Transaction Signing Plugin — v1 Spec + +Status: Approved (v1 subset). Supersedes the wallet-signing intent of PR #211, +which is retained only as a prototype of the catalog/validation layer. + +## 1. Problem + +Oracle agents can reason about IXO transactions but cannot get them signed. The +user's keys live in their Portal wallet on the frontend (SignX). We need a +plugin that lets an agent turn a conversation into a validated IXO transaction +and hand it to the user's wallet to sign — without the oracle ever holding keys, +signing, or broadcasting. + +## 2. Goal + +> understand intent → collect missing fields → validate strictly → disclose risk +> and get explicit confirmation → dispatch a `sign_transaction` action to the +> Portal frontend → the user signs in their own wallet → the result (tx hash, +> rejection, timeout, or error) comes back into the chat. + +## 3. Non-goals (v1) + +- No server-side signing, broadcasting, or key custody. The wallet owns that. +- No chain queries (balances, state). Write-path only. +- No automatic execution — a human always signs in their wallet. +- Modules deferred to a follow-on milestone: `bonds`, `liquidstake`, `names`, + and broad generic Cosmos authz (`MsgGrant`/`MsgExec`). Deferred until proto + support and wallet execution paths are confirmed. (`names` has no codegen in + `@ixo/impactxclient-sdk@2.4.1`, so it cannot be supported yet.) + +## 4. v1 transaction coverage + +Included modules (validated against `@ixo/impactxclient-sdk` protobufs): + +- `entity` — create, update, update-verified, transfer, create-account, and the + concrete entity-account authz routes (grant/revoke). +- `iid` — IID/DID document lifecycle (controllers, verifications, services, + linked resources/claims/entities, accorded rights, contexts, deactivate). +- `claims` — collection + claim lifecycle (create-collection, submit, evaluate, + dispute, payments, collection policy updates, claim authorization). +- `token` — impact-credit lifecycle (create, mint, transfer, transfer-credit, + retire, cancel, pause, stop). +- `smart-account` — authenticator lifecycle (add/remove authenticator, + set-active-state). + +Any message that cannot be confirmed against the SDK proto set is dropped from +the v1 catalog rather than guessed. Two messages from the PR #211 prototype were +dropped for this reason: the entire `names` module and `MsgUpdateCollectionQuota` +do not exist in `@ixo/impactxclient-sdk@2.4.1`. + +## 5. Architecture + +The load-bearing primitive already exists: `callAgAction` (`@ixo/common`) emits +an `action_call` event to the frontend and awaits the matching +`action_call_result`. This is the exact mechanism the bundled `agui` plugin +uses. The transaction plugin reuses it directly — validated transaction data is +carried in the action args and is never round-tripped through the LLM. + +```mermaid +sequenceDiagram + participant U as User + participant A as Agent + participant P as ixo-transaction plugin + participant FE as Portal FE (sign_transaction handler) + participant W as Wallet / SignX + U->>A: "create a new domain" or /ixo entity create + A->>P: classify_ixo_transaction_intent / validate_ixo_transaction_draft + P-->>A: resolved Msg, required fields, risks + A->>U: discloses risks, collects missing fields + confirmation + U->>A: provides fields + accepts risks + A->>P: sign_ixo_transaction(draft + riskConfirmation) + Note over P: validate, build EncodeObject[], enforce risk + testnet-first gates + P->>FE: callAgAction(toolName "sign_transaction", {messages, memo, network, metadata}) + FE->>W: transactSignX(messages, memo) + W-->>FE: DeliverTxResponse (tx hash) or rejection + FE-->>P: action_call_result { success, result | error } + P-->>A: { status: signed|rejected|timeout|error, ... } + A->>U: reports tx hash or the failure clearly +``` + +## 6. Frontend contract + +The Portal frontend mounts one hook from `ixo-transaction/react`, which registers +a **hidden** `sign_transaction` AG-UI action and wires it to the wallet: + +```ts +import { useIxoTransactionSigningAction } from 'ixo-transaction/react'; + +function OraclePortalChat() { + useIxoTransactionSigningAction(); + return ; +} +``` + +The hook registers the action with `exposeToAgent: false` — so it is executable +over the websocket but is **not** advertised to the agent as a tool. The agent +can only reach the wallet through the validated `sign_ixo_transaction` server +tool, never by calling `sign_transaction` directly. + +Action args dispatched by the plugin (proto-JSON on the wire): + +```ts +{ + action: 'sign_transaction'; + network: 'devnet' | 'testnet' | 'mainnet'; + messages: Array<{ typeUrl: string; value: Record }>; + memo?: string; + intent: { source; module; action; messageName; typeUrl; confidence; ambiguities }; + risks: string[]; + riskLevel: 'low' | 'medium' | 'high' | 'critical'; + requiresConfirmation: boolean; + riskConfirmation?; testnetReceipt?; overrideMainnet?; overrideReason?; +} +``` + +**Proto encoding (BE → FE).** Messages cross as proto-JSON `{ typeUrl, value }`. +The hook converts each one into a wallet-ready Cosmos `EncodeObject` via the IXO +SDK's generated `fromJSON` (`packages/ixo-transaction/src/react/proto.ts`) right +before calling `transactSignX`. `fromJSON` decodes the lossy fields (`bytes` from +base64, `Long`, `Timestamp`) into their real runtime types, so the wallet +encodes them correctly. Heavy proto codecs stay on the FE only. + +Result contract (`action_call_result.result`): `{ success: boolean, error?, ... }`. +`callAgAction` rejects when `success === false`, when an `error` is present, or on +timeout; the server tool catches and maps to a structured status. + +## 7. Plugin design + +| Aspect | Decision | +| ------------ | ----------------------------------------------------------------------------------------------------------------------- | +| Location | Standalone package `packages/ixo-transaction` with `/qiforge` (server plugin) and `/react` (FE hook) subpaths | +| Name | `ixo-transaction` | +| Visibility | `on-demand` — opted into per-oracle via `plugins: [new IxoTransactionPlugin()]`; loaded via `load_capability` | +| Category | `integration` | +| Dependencies | `@ixo/oracle-runtime/plugin-api` (peer), `@ixo/common` (`callAgAction`); FE: `@ixo/oracles-client-sdk` + the SDK codecs | +| Signing | Dispatches the hidden `sign_transaction` AG-UI action; 120s timeout (wallet signing is human-paced) | + +### Tools + +| Tool | Effect | Output | +| --------------------------------- | ------------------------------------------ | ----------------------------------------------- | +| `list_ixo_transaction_routes` | none (read-only) | supported routes, fields, risk levels | +| `classify_ixo_transaction_intent` | none (read-only) | resolved route + confidence + ambiguities | +| `validate_ixo_transaction_draft` | none (read-only) | canonical `{typeUrl,value}`, risks, gate status | +| `sign_ixo_transaction` | dispatches `sign_transaction` to FE wallet | `{ status, transactionHash?, error? }` | + +Only `sign_ixo_transaction` touches the wallet. + +## 8. `sign_ixo_transaction` behaviour + +1. Parse draft (one of: `input` / `command` / `messageType`+`action` / `typeUrl`, + plus `value`, `network`, `memo`, `riskConfirmation`, `testnetReceipt`, + `overrideMainnet`+`overrideReason`). +2. Resolve intent → `MessageSpec`; build + strictly validate `value` + (reject unknown/missing/malformed: DID, `ixo1` address, integer micro-units, + timestamps). +3. Risk gate: if the message is risky and `riskConfirmation.confirmed !== true`, + refuse and return the risks for the agent to surface. +4. Mainnet gate: `network === 'mainnet'` requires a successful `testnetReceipt` + or explicit `overrideMainnet` + `overrideReason`. +5. Require `ctx.session.id` (wallet signing is Portal-only); otherwise return a + clear error instead of dispatching. +6. `callAgAction({ sessionId, toolCallId, toolName: 'sign_transaction', +args: { messages, memo, network, metadata }, timeout })`. +7. Map the outcome to `{ status: 'signed' | 'rejected' | 'timeout' | 'error', … }`. + Never report success on a rejection or timeout. + +## 9. Risk model + +- Agent proposes; the human signs in their own wallet. The oracle cannot move + funds unilaterally. +- Explicit, itemised risk acceptance required before: ownership transfer, + funds/credit movement, authority grants, claim evaluation/payment changes, + account/authenticator changes, and any mainnet transaction. +- Testnet-first: default `testnet`; mainnet requires a testnet receipt or a + recorded explicit override. +- `sign_ixo_transaction` is `on-demand`, kept out of the default toolset. + +## 10. Testing (against the real runtime — no stubs) + +- Unit: intent routing (slash/NL/typeUrl/aliases), validation (happy + each + rejection), risk + mainnet gates. +- Manifest: instantiate the real `OraclePlugin`; assert `validateManifest` + passes and the four tools register. +- Round-trip: drive `sign_ixo_transaction` against the real `rootEventEmitter` + — assert it emits `action_call` with `toolName: 'sign_transaction'` and a + correct `EncodeObject[]`, then resolve `action_call_result` and assert the + mapped status. Assert refusal on missing risk confirmation, + mainnet-without-receipt, and missing session; assert timeout and rejection + mapping. + +## 11. Reused vs replaced (from PR #211) + +Reused (ported, validated): message catalog, intent routing, alias/typo +handling, validation + risk + testnet gating, Zod field primitives. + +Replaced: invented `signxTransaction` / `ixo.portal.iframe.v1` output, the +mocked-runtime tests and fabricated `@ixo/oracle-runtime` type stub, and the +"return JSON to the LLM" flow — with a real `action_call` round-trip carrying +`EncodeObject[]` to the frontend `sign_transaction` handler, plus integration +tests against real runtime behaviour. + +## 12. Follow-on milestones + +- Add `bonds`, `liquidstake`, and generic authz once proto + wallet execution + paths are confirmed. +- Optional server-side message normalisation via `@ixo/impactxclient-sdk` + `fromPartial` if frontend-registry encoding proves insufficient.