From c35adc0778995633a2062f0fde116a5a8a4febd0 Mon Sep 17 00:00:00 2001 From: Matt Schoch Date: Mon, 26 Aug 2024 20:04:44 -0400 Subject: [PATCH] Transfer & Approval demo --- .../approvals-by-spending-limit/.env.default | 22 +- .../approvals-by-spending-limit/1-setup.ts | 48 ++++ .../2-add-destination.ts | 30 +++ .../3-add-account.ts | 29 +++ .../4-transfer-a-to-b.ts | 82 ++++++ .../5-approve-transfer.ts | 59 +++++ .../approvals-by-spending-limit/README.md | 28 ++ .../approvals-by-spending-limit/approve.ts | 61 ----- .../armory.account.ts | 28 -- .../armory.data.ts | 240 ------------------ .../approvals-by-spending-limit/armory.sdk.ts | 136 ++++------ examples/approvals-by-spending-limit/data.ts | 227 +++++++++++++++++ .../package-lock.json | 36 ++- .../approvals-by-spending-limit/package.json | 4 +- examples/approvals-by-spending-limit/setup.ts | 100 -------- .../approvals-by-spending-limit/transfer.ts | 143 ----------- .../src/lib/dev.fixture.ts | 24 ++ 17 files changed, 625 insertions(+), 672 deletions(-) create mode 100644 examples/approvals-by-spending-limit/1-setup.ts create mode 100644 examples/approvals-by-spending-limit/2-add-destination.ts create mode 100644 examples/approvals-by-spending-limit/3-add-account.ts create mode 100644 examples/approvals-by-spending-limit/4-transfer-a-to-b.ts create mode 100644 examples/approvals-by-spending-limit/5-approve-transfer.ts delete mode 100644 examples/approvals-by-spending-limit/approve.ts delete mode 100644 examples/approvals-by-spending-limit/armory.account.ts delete mode 100644 examples/approvals-by-spending-limit/armory.data.ts create mode 100644 examples/approvals-by-spending-limit/data.ts delete mode 100644 examples/approvals-by-spending-limit/setup.ts delete mode 100644 examples/approvals-by-spending-limit/transfer.ts diff --git a/examples/approvals-by-spending-limit/.env.default b/examples/approvals-by-spending-limit/.env.default index 47a515e69..03b4de37d 100644 --- a/examples/approvals-by-spending-limit/.env.default +++ b/examples/approvals-by-spending-limit/.env.default @@ -1,12 +1,14 @@ -ADMIN_USER_ADDR=0xaaa... -MEMBER_USER_ADDR=-0xbbb... -SYSTEM_MANAGER_KEY=0xddd... -MEMBER_USER_CRED=0xeee... +# Already provisioned +AUTH_HOST=http://localhost:3005 +VAULT_HOST=http://localhost:3011 +CLIENT_ID=approval-example-xx +CLIENT_SECRET=... # Get this when you provision your client -AUTH_HOST=https://auth.armory.narval.xyz -AUTH_CLIENT_ID=narval-example -VAULT_HOST=https://vault.armory.narval.xyz -VAULT_CLIENT_ID=narval-example +# Datastore signing key set when provisioning the client, do not change this. +# This MUST match the one you set during provision +DATA_STORE_SIGNER_ADDRESS=0x000c0d191308a336356bee3813cc17f6868972c4 # This is the Root from dev.fixture.ts +DATA_STORE_SIGNER_PRIVATE_KEY=0xa95b097938cc1d1a800d2b10d2a175f979613c940868460fd66830059fc1e418 -AUTH_API_KEY=armory-admin-api-key -VAULT_API_KEY=vault-admin-api-key +# User Credentials - these can be changed & they'll be used when running `setup` +ADMIN_USER_PRIVATE_KEY=0x454c9f13f6591f6482b17bdb6a671a7294500c7dd126111ce1643b03b6aeb354 # Alice from dev.fixture.ts +MEMBER_USER_PRIVATE_KEY=0x569a6614716a76fdb9cf21b842d012add85e680b51fd4fb773109a93c6c4f307 # Bob from dev.fixture.ts diff --git a/examples/approvals-by-spending-limit/1-setup.ts b/examples/approvals-by-spending-limit/1-setup.ts new file mode 100644 index 000000000..17ec1246e --- /dev/null +++ b/examples/approvals-by-spending-limit/1-setup.ts @@ -0,0 +1,48 @@ +/* eslint-disable no-console */ +import { hexSchema } from '@narval-xyz/armory-sdk/policy-engine-shared' +import 'dotenv/config' +import { getArmoryClients } from './armory.sdk' +import { buildEntities, policies } from './data' + +// Setup is going to assume we already have a Client provisioned, and we have a PK for the datastore (same key for policies & entities) +// Setup will bootstrap the initial policy & entities +// 1. Create a system-manager user with the same PK as the datastore, so it can generate new Accounts & update data itself. +// 2. Create an Admin User, from a PK in the .env +// 3. Create a Member User, from a PK in the .env +// 4. Set the policies we'll be using + +const main = async () => { + console.log('🚀 Starting...\n') + const dataStoreSignerPrivateKey = hexSchema.parse(process.env.DATA_STORE_SIGNER_PRIVATE_KEY) + const adminUserPrivateKey = hexSchema.parse(process.env.ADMIN_USER_PRIVATE_KEY) + const memberUserPrivateKey = hexSchema.parse(process.env.MEMBER_USER_PRIVATE_KEY) + const vaultHost = process.env.VAULT_HOST + const authHost = process.env.AUTH_HOST + const clientId = process.env.CLIENT_ID + const clientSecret = process.env.CLIENT_SECRET + + if (!authHost || !vaultHost || !clientId || !clientSecret) { + throw new Error('Missing configuration') + } + const armory = await getArmoryClients(dataStoreSignerPrivateKey, { + clientId, + clientSecret, + vaultHost, + authHost + }) + + console.log('🔒 Setting policies...\n') + await armory.policyStoreClient.signAndPush(policies) + + console.log('🏗️ Setting initial entity data... \n') + const entities = buildEntities({ + adminUserPrivateKey, + memberUserPrivateKey, + dataStoreSignerPrivateKey + }) + await armory.entityStoreClient.signAndPush(entities) + + console.log('✅ Setup completed successfully \n') +} + +main().catch(console.error) diff --git a/examples/approvals-by-spending-limit/2-add-destination.ts b/examples/approvals-by-spending-limit/2-add-destination.ts new file mode 100644 index 000000000..3f45bcb9d --- /dev/null +++ b/examples/approvals-by-spending-limit/2-add-destination.ts @@ -0,0 +1,30 @@ +/* eslint-disable no-console */ +import { AddressBookAccountEntity } from '@narval-xyz/armory-sdk/policy-engine-shared' +import 'dotenv/config' +import { uniqBy } from 'lodash/fp' +import { getArmoryClientsFromEnv } from './armory.sdk' + +// Adds the below Destination account into the Address Book +const main = async () => { + console.log('🚀 Add Destination - Starting...\n') + const armory = await getArmoryClientsFromEnv() + + console.log('🏗️ Adding `0x9f38879167acCf7401351027EE3f9247A71cd0c5` as an `internal` destination... \n') + + const destination: AddressBookAccountEntity = { + chainId: 1, + address: '0x9f38879167acCf7401351027EE3f9247A71cd0c5', // Engineering account from dev.fixture.ts + id: 'eip155:1:0x9f38879167acCf7401351027EE3f9247A71cd0c5', + classification: 'internal' + } + + const entities = await armory.entityStoreClient.fetch() + const addressBook = uniqBy('id', [...entities.data.addressBook, destination]) + entities.data.addressBook = addressBook + + await armory.entityStoreClient.signAndPush(entities.data) + + console.log('✅ Add Destination - Complete \n') +} + +main().catch(console.error) diff --git a/examples/approvals-by-spending-limit/3-add-account.ts b/examples/approvals-by-spending-limit/3-add-account.ts new file mode 100644 index 000000000..b760bf5ab --- /dev/null +++ b/examples/approvals-by-spending-limit/3-add-account.ts @@ -0,0 +1,29 @@ +/* eslint-disable no-console */ +import { AccountEntity } from '@narval-xyz/armory-sdk/policy-engine-shared' +import 'dotenv/config' +import { uniqBy } from 'lodash/fp' +import { getArmoryClientsFromEnv } from './armory.sdk' + +// Adds the below managed account into the data store +const main = async () => { + console.log('🚀 Add Account - Starting...\n') + const armory = await getArmoryClientsFromEnv() + + console.log('🏗️ Adding `0x76d1b7f9b3F69C435eeF76a98A415332084A856F` as an managed account... \n') + + const account: AccountEntity = { + id: 'acct-ops-account-b', + address: '0x76d1b7f9b3F69C435eeF76a98A415332084A856F', // Operations account from dev.fixture.ts + accountType: 'eoa' + } + + const entities = await armory.entityStoreClient.fetch() + const accounts = uniqBy('id', [...entities.data.accounts, account]) + entities.data.accounts = accounts + + await armory.entityStoreClient.signAndPush(entities.data) + + console.log('✅ Add Account - Complete \n') +} + +main().catch(console.error) diff --git a/examples/approvals-by-spending-limit/4-transfer-a-to-b.ts b/examples/approvals-by-spending-limit/4-transfer-a-to-b.ts new file mode 100644 index 000000000..24940b65d --- /dev/null +++ b/examples/approvals-by-spending-limit/4-transfer-a-to-b.ts @@ -0,0 +1,82 @@ +/* eslint-disable no-console */ +import { + AuthClient, + AuthConfig, + Decision, + Request, + TransactionRequest, + buildSignerEip191, + privateKeyToJwk +} from '@narval-xyz/armory-sdk' +import { hexSchema } from '@narval-xyz/armory-sdk/policy-engine-shared' +import 'dotenv/config' +import { v4 } from 'uuid' + +const transactionRequest = { + from: '0x0301e2724a40E934Cce3345928b88956901aA127', // Account A + to: '0x76d1b7f9b3F69C435eeF76a98A415332084A856F', // Account B + chainId: 1, + value: '0x429D069189E0000', // 0.3 ETH + gas: 123n, + maxFeePerGas: 789n, + maxPriorityFeePerGas: 456n, + nonce: 193 +} as TransactionRequest + +const nonce = v4() +const request: Request = { + action: 'signTransaction', + resourceId: 'acct-treasury-account-a', // account.id in data.ts + transactionRequest, + nonce +} + +const main = async () => { + console.log( + `🚀 Transferring 0.3 ETH - from \x1b[32m${request.resourceId}\x1b[0m to \x1b[34macct-ops-account-b\x1b[0m \n` + ) + const memberUserPrivateKey = hexSchema.parse(process.env.MEMBER_USER_PRIVATE_KEY) + const host = process.env.AUTH_HOST + const clientId = process.env.CLIENT_ID + if (!host || !clientId) { + throw new Error('Missing configuration') + } + + const authJwk = privateKeyToJwk(memberUserPrivateKey) + const signer = buildSignerEip191(memberUserPrivateKey) + const authConfig: AuthConfig = { + host, + clientId, + signer: { + jwk: authJwk, + alg: 'EIP191', + sign: signer + } + } + const auth = new AuthClient(authConfig) + + // Make the authorization request + const response = await auth.authorize(request) + + switch (response.decision) { + case Decision.PERMIT: { + console.log('✅ Transaction approved \n') + console.log('🔐 Approval token: \n', response.accessToken.value) + break + } + case Decision.CONFIRM: { + console.log('🔐 Request needs approvals', { authId: response.authId }, '\n') + console.table(response.approvals.missing) + // ... existing code ... + console.log(`To approve, run:\n\n\t\x1b[1m\x1b[33mtsx 5-approve-transfer.ts ${response.authId}\x1b[0m\n`) + // ... existing code ... + break + } + case Decision.FORBID: { + console.error('❌ Unauthorized') + console.log('🔍 Response', response, '\n') + } + } +} + +main().catch(console.error) diff --git a/examples/approvals-by-spending-limit/5-approve-transfer.ts b/examples/approvals-by-spending-limit/5-approve-transfer.ts new file mode 100644 index 000000000..f4dc3480b --- /dev/null +++ b/examples/approvals-by-spending-limit/5-approve-transfer.ts @@ -0,0 +1,59 @@ +/* eslint-disable no-console */ +import { AuthClient, AuthConfig, Decision, buildSignerEip191, privateKeyToJwk } from '@narval-xyz/armory-sdk' +import { hexSchema } from '@narval-xyz/armory-sdk/policy-engine-shared' +import 'dotenv/config' +import minimist from 'minimist' + +const main = async () => { + const args = minimist(process.argv.slice(2)) + const authId = args._[0] + + console.log(`\x1b[32m🚀 Approving Transfer as Admin\x1b[0m - \x1b[36m${authId}\x1b[0m \n`) + + const adminUserPrivateKey = hexSchema.parse(process.env.ADMIN_USER_PRIVATE_KEY) + const host = process.env.AUTH_HOST + const clientId = process.env.CLIENT_ID + if (!host || !clientId) { + throw new Error('Missing configuration') + } + + const authJwk = privateKeyToJwk(adminUserPrivateKey) + const signer = buildSignerEip191(adminUserPrivateKey) + const authConfig: AuthConfig = { + host, + clientId, + signer: { + jwk: authJwk, + alg: 'EIP191', + sign: signer + } + } + const auth = new AuthClient(authConfig) + + const authRequest = await auth.getAuthorizationById(authId) + console.log('🔎 Found pending request') + + await auth.approve(authId) + const approvedAuthorizationRequest = await auth.getAuthorizationById(authId) + const result = approvedAuthorizationRequest.evaluations.find(({ decision }) => decision === Decision.PERMIT) + + switch (result?.decision) { + case Decision.PERMIT: { + console.log('✅ Transaction approved \n') + console.log('🔐 Approval token: \n', result.signature) + break + } + case Decision.CONFIRM: { + console.log('🔐 Request still needs approvals', { authId: approvedAuthorizationRequest.id }, '\n') + console.table(result.approvalRequirements?.missing) + break + } + case Decision.FORBID: + default: { + console.error('❌ Unauthorized') + console.log('🔍 Response', result, '\n') + } + } +} + +main().catch(console.error) diff --git a/examples/approvals-by-spending-limit/README.md b/examples/approvals-by-spending-limit/README.md index caf0ba392..ccb581d85 100644 --- a/examples/approvals-by-spending-limit/README.md +++ b/examples/approvals-by-spending-limit/README.md @@ -1 +1,29 @@ # Collect approval before authorizing a request +Setup + +`npm i` (run from this directory) + +Run each command in order + +1. Set up Policies & base Entity data (see data.ts) - as the system manager + + `tsx 1-setup.ts` + +2. Add a new Destination to the address book - as the system manager + + `tsx 2-add-destination.ts` + +3. Add a second Account to be managed - as the system manager + + `tsx 3-add-account.ts` + +4. Transfer from Account A to Account B - as the Member User + + Run the transfer 4 times in a row; the 4th requires an Approval + + `tsx 4-transfer-a-to-b.ts` + +5. Use the ID from the pending request to approve - as the Admin User + + `tsx 5-approve-transfer.ts $REQUEST_ID` + diff --git a/examples/approvals-by-spending-limit/approve.ts b/examples/approvals-by-spending-limit/approve.ts deleted file mode 100644 index 561a4c68b..000000000 --- a/examples/approvals-by-spending-limit/approve.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { AuthClient, AuthConfig, buildSignerEip191, privateKeyToJwk } from '@narval/armory-sdk' -import { hexSchema } from '@narval/policy-engine-shared' -import 'dotenv/config' -import minimist from 'minimist' - -const main = async () => { - console.log('Starting...') - - const args = minimist(process.argv.slice(2)) - - const userType = args.user - const authId = args._[0] - - // Check if userType is provided - if (!userType) { - console.error('Please specify the user type: --user=member or --user=admin') - process.exit(1) - } - - // Check if authId is provided - if (!authId) { - console.error('Please provide the authId as the second argument') - process.exit(1) - } - - const CRED = - userType === 'admin' ? hexSchema.parse(process.env.ADMIN_USER_CRED) : hexSchema.parse(process.env.MEMBER_USER_CRED) - - const AUTH_HOST = process.env.AUTH_HOST - const AUTH_CLIENT_ID = process.env.AUTH_CLIENT_ID - - if (!AUTH_HOST || !AUTH_CLIENT_ID) { - console.error('Missing environment variables') - return - } - - const authJwk = privateKeyToJwk(CRED) - - const authConfig: AuthConfig = { - host: AUTH_HOST, - clientId: AUTH_CLIENT_ID, - signer: { - jwk: authJwk, - alg: 'EIP191', - sign: buildSignerEip191(CRED) - } - } - - const auth = new AuthClient(authConfig) - - const authRequest = await auth.getAuthorizationById(authId) - console.log('### authRequestBeforeApproval', JSON.stringify(authRequest, null, 2)) - - await auth.approve(authId) - - const approvedAuthorizationRequest = await auth.getAuthorizationById(authId) - console.log('### approvedAuthorizationRequest', JSON.stringify(approvedAuthorizationRequest, null, 2)) - console.log('Done') -} - -main().catch(console.error) diff --git a/examples/approvals-by-spending-limit/armory.account.ts b/examples/approvals-by-spending-limit/armory.account.ts deleted file mode 100644 index 88140bab9..000000000 --- a/examples/approvals-by-spending-limit/armory.account.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { - AuthClient, - AuthConfig, - DataStoreConfig, - EntityStoreClient, - PolicyStoreClient, - VaultClient, - VaultConfig -} from '@narval-xyz/armory-sdk' - -export const armoryClient = (configs: { - auth: AuthConfig - vault: VaultConfig - entityStore: DataStoreConfig - policyStore: DataStoreConfig -}) => { - const authClient = new AuthClient(configs.auth) - const vaultClient = new VaultClient(configs.vault) - const entityStoreClient = new EntityStoreClient(configs.entityStore) - const policyStoreClient = new PolicyStoreClient(configs.policyStore) - - return { - authClient, - vaultClient, - entityStoreClient, - policyStoreClient - } -} diff --git a/examples/approvals-by-spending-limit/armory.data.ts b/examples/approvals-by-spending-limit/armory.data.ts deleted file mode 100644 index c39deea40..000000000 --- a/examples/approvals-by-spending-limit/armory.data.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { - AuthClient, - EntityStoreClient, - Permission, - PolicyStoreClient, - PublicKey, - VaultClient -} from '@narval-xyz/armory-sdk' -import { AccountEntity, CredentialEntity } from '@narval-xyz/armory-sdk/policy-engine-shared' -import { addressToKid, publicKeySchema } from '@narval-xyz/armory-sdk/signature' -import { - Action, - Criterion, - EntityType, - Policy, - Then, - UserEntity, - UserRole, - ValueOperators -} from '@narval/policy-engine-shared' -import { Curves, KeyTypes, SigningAlg, jwkEoaSchema, privateKeyToJwk } from '@narval/signature' -import { v4 } from 'uuid' -import { Hex } from 'viem' - -const setPolicies = async (policyStoreClient: PolicyStoreClient) => { - const policies: Policy[] = [ - { - id: v4(), - description: 'Allows admin to do anything', - when: [ - { - criterion: Criterion.CHECK_PRINCIPAL_ROLE, - args: [UserRole.ADMIN] - } - ], - then: Then.PERMIT - }, - { - id: v4(), - description: 'Allows managers to read, create and import wallets', - when: [ - { - criterion: Criterion.CHECK_PRINCIPAL_ROLE, - args: [UserRole.MANAGER] - }, - { - criterion: Criterion.CHECK_PERMISSION, - args: ['wallet:read', 'wallet:create', 'wallet:import'] - } - ], - then: Then.PERMIT - }, - { - id: v4(), - description: 'Allows members to read wallets', - when: [ - { - criterion: Criterion.CHECK_PRINCIPAL_ROLE, - args: [UserRole.MEMBER] - }, - { - criterion: Criterion.CHECK_PERMISSION, - args: ['wallet:read'] - } - ], - then: Then.PERMIT - }, - { - id: 'members-can-transfer-1-eth-per-minute', - description: 'members can transfer 1 ETH', - when: [ - { - criterion: 'checkAction', - args: ['signTransaction'] - }, - { - criterion: 'checkPrincipalRole', - args: ['member'] - }, - { - criterion: 'checkIntentType', - args: ['transferNative'] - }, - { - criterion: 'checkIntentToken', - args: ['eip155:1/slip44:60'] - }, - { - criterion: 'checkSpendingLimit', - args: { - limit: '1000000000000000000', - operator: 'lt' as ValueOperators, - timeWindow: { - type: 'rolling', - value: 30 - }, - filters: { - perPrincipal: true, - tokens: ['eip155:1/slip44:60'] - } - } - } - ], - then: 'permit' - }, - { - id: 'members-can-transfer-gt-1-eth-per-minute-with-approval', - description: 'members transfers for more than 1 ETH per day requires an admin approval', - when: [ - { - criterion: 'checkAction', - args: ['signTransaction'] - }, - { - criterion: 'checkPrincipalRole', - args: ['member'] - }, - { - criterion: 'checkIntentType', - args: ['transferNative'] - }, - { - criterion: 'checkIntentToken', - args: ['eip155:1/slip44:60'] - }, - { - criterion: 'checkSpendingLimit', - args: { - limit: '1000000000000000000', - operator: 'gte' as ValueOperators, - timeWindow: { - type: 'rolling', - value: 30 - }, - filters: { - perPrincipal: true, - tokens: ['eip155:1/slip44:60'] - } - } - }, - { - criterion: 'checkApprovals', - args: [ - { - approvalCount: 1, - countPrincipal: false, - approvalEntityType: 'Narval::UserRole' as EntityType, - entityIds: ['admin'] - } - ] - } - ], - then: 'permit' - } - ] - await policyStoreClient.signAndPush(policies) -} - -export const createPublicKey = (credInput: Hex): PublicKey => { - return credInput.length === 42 - ? jwkEoaSchema.parse({ - kty: KeyTypes.EC, - crv: Curves.SECP256K1, - alg: SigningAlg.ES256K, - kid: addressToKid(credInput), - addr: credInput - }) - : publicKeySchema.parse(privateKeyToJwk(credInput, 'ES256K')) -} - -const setEntities = async ( - entityStoreClient: EntityStoreClient, - userAndCredentials: { credential: Hex; role: UserRole; id?: string }[], - accounts: AccountEntity[] -) => { - const entitiesInput = userAndCredentials.reduce( - (acc, { credential: credInput, role, id }) => { - const user: UserEntity = { - id: id || v4(), - role - } - - const publicKey = createPublicKey(credInput) - - const cred: CredentialEntity = { - id: publicKey.kid, - key: publicKey, - userId: user.id - } - - acc.users.push(user) - acc.credentials.push(cred) - - return acc - }, - { - users: [] as UserEntity[], - credentials: [] as CredentialEntity[], - accounts - } - ) - - await entityStoreClient.signAndPush(entitiesInput) -} - -export const setInitialState = async ({ - armory, - userAndCredentials -}: { - armory: { - vaultClient: VaultClient - authClient: AuthClient - entityStoreClient: EntityStoreClient - policyStoreClient: PolicyStoreClient - } - userAndCredentials: { credential: Hex; role: UserRole; id?: string }[] -}) => { - const { vaultClient, authClient, entityStoreClient, policyStoreClient } = armory - - await setPolicies(policyStoreClient) - await setEntities(entityStoreClient, userAndCredentials, []) - - const accessToken = await authClient.requestAccessToken({ - action: Action.GRANT_PERMISSION, - resourceId: 'vault', - nonce: v4(), - permissions: [Permission.WALLET_IMPORT, Permission.WALLET_CREATE, Permission.WALLET_READ] - }) - - const { account } = await vaultClient.generateWallet({ accessToken }) - - await setEntities(entityStoreClient, userAndCredentials, [ - { - id: account.id, - accountType: 'eoa', - address: account.address as Hex - } - ]) - return account -} diff --git a/examples/approvals-by-spending-limit/armory.sdk.ts b/examples/approvals-by-spending-limit/armory.sdk.ts index 64a401ea1..dbb401811 100644 --- a/examples/approvals-by-spending-limit/armory.sdk.ts +++ b/examples/approvals-by-spending-limit/armory.sdk.ts @@ -1,95 +1,43 @@ import { - AuthAdminClient, + AuthClient, AuthConfig, DataStoreConfig, + EntityStoreClient, Hex, - VaultAdminClient, + PolicyStoreClient, + VaultClient, VaultConfig, - buildSignerForAlg, - createHttpDataStore, - getPublicKey, + buildSignerEip191, privateKeyToJwk } from '@narval-xyz/armory-sdk' -import { format } from 'date-fns' -import { v4 } from 'uuid' +import { hexSchema } from '@narval-xyz/armory-sdk/policy-engine-shared' +import { privateKeyToAddress } from 'viem/accounts' -const createClient = async ( - SYSTEM_MANAGER_KEY: Hex, - { - authHost, - authAdminApiKey, - vaultHost, - vaultAdminApiKey - }: { - vaultHost: string - authHost: string - authAdminApiKey: string - vaultAdminApiKey: string - } -) => { - const clientId = v4() - const authAdminClient = new AuthAdminClient({ - host: authHost, - adminApiKey: authAdminApiKey - }) - const vaultAdminClient = new VaultAdminClient({ - host: vaultHost, - adminApiKey: vaultAdminApiKey - }) - - const jwk = privateKeyToJwk(SYSTEM_MANAGER_KEY) - const publicKey = getPublicKey(jwk) - - const authClient = await authAdminClient.createClient({ - id: clientId, - name: `Armory SDK E2E test ${format(new Date(), 'dd/MM/yyyy HH:mm:ss')}`, - dataStore: createHttpDataStore({ - host: authHost, - clientId, - keys: [publicKey] - }), - useManagedDataStore: true - }) - - await vaultAdminClient.createClient({ - clientId: authClient.id, - engineJwk: authClient.policyEngine.nodes[0].publicKey - }) - - return { - clientId - } -} - -export const getArmoryConfig = async ( - SYSTEM_MANAGER_KEY: Hex, +export const getArmoryClients = async ( + dataStoreSignerPrivateKey: Hex, { + clientId, + clientSecret, authHost, - authAdminApiKey, - vaultHost, - vaultAdminApiKey + vaultHost }: { + clientId: string + clientSecret: string vaultHost: string authHost: string - authAdminApiKey: string - vaultAdminApiKey: string } ) => { - const { clientId } = await createClient(SYSTEM_MANAGER_KEY, { - authAdminApiKey, - authHost, - vaultAdminApiKey, - vaultHost - }) + const address = privateKeyToAddress(dataStoreSignerPrivateKey) + // Note: when manually configuring, we set it up w/ an EOA as the datastore signer, so we need to match the keyId here + const jwk = privateKeyToJwk(dataStoreSignerPrivateKey, 'ES256K', address) - const jwk = privateKeyToJwk(SYSTEM_MANAGER_KEY) const auth: AuthConfig = { host: authHost, clientId, signer: { jwk, - alg: 'ES256K', - sign: await buildSignerForAlg(jwk) + alg: 'EIP191', + sign: await buildSignerEip191(dataStoreSignerPrivateKey) } } @@ -98,35 +46,61 @@ export const getArmoryConfig = async ( clientId, signer: { jwk, - alg: 'ES256K', - sign: await buildSignerForAlg(jwk) + alg: 'EIP191', + sign: await buildSignerEip191(dataStoreSignerPrivateKey) } } const entityStore: DataStoreConfig = { host: authHost, clientId, + clientSecret, signer: { jwk, - alg: 'ES256K', - sign: await buildSignerForAlg(jwk) + alg: 'EIP191', + sign: await buildSignerEip191(dataStoreSignerPrivateKey) } } const policyStore: DataStoreConfig = { host: authHost, clientId, + clientSecret, signer: { jwk, - alg: 'ES256K', - sign: await buildSignerForAlg(jwk) + alg: 'EIP191', + sign: await buildSignerEip191(dataStoreSignerPrivateKey) } } + const authClient = new AuthClient(auth) + const vaultClient = new VaultClient(vault) + const entityStoreClient = new EntityStoreClient(entityStore) + const policyStoreClient = new PolicyStoreClient(policyStore) + return { - auth, - vault, - entityStore, - policyStore + authClient, + vaultClient, + entityStoreClient, + policyStoreClient + } +} + +export const getArmoryClientsFromEnv = async () => { + const dataStoreSignerPrivateKey = hexSchema.parse(process.env.DATA_STORE_SIGNER_PRIVATE_KEY) + const vaultHost = process.env.VAULT_HOST + const authHost = process.env.AUTH_HOST + const clientId = process.env.CLIENT_ID + const clientSecret = process.env.CLIENT_SECRET + + if (!authHost || !vaultHost || !clientId || !clientSecret) { + throw new Error('Missing configuration') } + + return getArmoryClients(dataStoreSignerPrivateKey, { + clientId, + clientSecret, + vaultHost, + authHost + }) } diff --git a/examples/approvals-by-spending-limit/data.ts b/examples/approvals-by-spending-limit/data.ts new file mode 100644 index 000000000..b53e363bb --- /dev/null +++ b/examples/approvals-by-spending-limit/data.ts @@ -0,0 +1,227 @@ +import { CredentialEntity } from '@narval-xyz/armory-sdk/policy-engine-shared' +import { publicKeySchema } from '@narval-xyz/armory-sdk/signature' +import { Entities, policySchema } from '@narval/policy-engine-shared' +import { privateKeyToJwk } from '@narval/signature' +import { Hex } from 'viem' +import { z } from 'zod' + +const baseEntities: Partial = { + users: [ + { + id: '1-admin-user', + role: 'admin' + }, + { + id: '2-member-user-q', + role: 'member' + }, + { + id: 'system-manager', + role: 'manager' + } + ], + credentials: [], + accounts: [ + // Treasury in dev.fixture.ts + { + id: 'acct-treasury-account-a', + address: '0x0301e2724a40E934Cce3345928b88956901aA127', + accountType: 'eoa' + } + ], + userGroups: [ + { + id: 'ug-treasury-group' + } + ], + userGroupMembers: [ + { + groupId: 'ug-treasury-group', + userId: '2-member-user-q' + } + ], + accountGroups: [ + { + id: 'ag-treasury-group' + } + ], + accountGroupMembers: [ + { + accountId: 'acct-treasury-account-a', + groupId: 'ag-treasury-group' + } + ], + addressBook: [] +} + +const basePolicies = [ + { + id: '1-admin-full-access', + description: 'Permit admins to perform any action', + when: [ + { + criterion: 'checkPrincipalRole', + args: ['admin'] + } + ], + then: 'permit' + }, + { + id: '2-system-manager-wallet-management', + description: 'Permit the policy & data manager key to import or generate wallets', + when: [ + { + criterion: 'checkPrincipalId', + args: ['system-manager'] + }, + { + criterion: 'checkAction', + args: ['grantPermission'] + }, + { + criterion: 'checkPermission', + args: ['wallet:read', 'wallet:create', 'wallet:import'] + } + ], + then: 'permit' + }, + { + id: '3-all-users-read-accounts', + description: 'Allows all users to read wallets', + when: [ + { + criterion: 'checkPrincipalRole', + args: ['member', 'admin', 'manager'] + }, + { + criterion: 'checkPermission', + args: ['wallet:read'] + } + ], + then: 'permit' + }, + { + id: '4-members-can-transfer-1-eth-per-minute', + description: 'Users with member role can transfer 1 ETH', + when: [ + { + criterion: 'checkAction', + args: ['signTransaction'] + }, + { + criterion: 'checkPrincipalRole', + args: ['member'] + }, + { + criterion: 'checkIntentType', + args: ['transferNative'] + }, + { + criterion: 'checkIntentToken', + args: ['eip155:1/slip44:60'] + }, + { + criterion: 'checkSpendingLimit', + args: { + limit: '1000000000000000000', + operator: 'lt', + timeWindow: { + type: 'rolling', + value: 60 + }, + filters: { + perPrincipal: true, + tokens: ['eip155:1/slip44:60'] + } + } + } + ], + then: 'permit' + }, + { + id: '5-members-can-transfer-gte-1-eth-per-minute-with-approval', + description: 'Users with member role requires an admin approval over 1 ETH per minute', + when: [ + { + criterion: 'checkAction', + args: ['signTransaction'] + }, + { + criterion: 'checkPrincipalRole', + args: ['member'] + }, + { + criterion: 'checkIntentType', + args: ['transferNative'] + }, + { + criterion: 'checkIntentToken', + args: ['eip155:1/slip44:60'] + }, + { + criterion: 'checkSpendingLimit', + args: { + limit: '1000000000000000000', + operator: 'gte', + timeWindow: { + type: 'rolling', + value: 60 + }, + filters: { + perPrincipal: true, + tokens: ['eip155:1/slip44:60'] + } + } + }, + { + criterion: 'checkApprovals', + args: [ + { + approvalCount: 1, + countPrincipal: false, + approvalEntityType: 'Narval::UserRole', + entityIds: ['admin'] + } + ] + } + ], + then: 'permit' + } +] + +export const policies = z.array(policySchema).parse(basePolicies) + +export const buildEntities = ({ + adminUserPrivateKey, + memberUserPrivateKey, + dataStoreSignerPrivateKey +}: { + adminUserPrivateKey: Hex + memberUserPrivateKey: Hex + dataStoreSignerPrivateKey: Hex +}) => { + const adminPublicKey = publicKeySchema.parse(privateKeyToJwk(adminUserPrivateKey, 'ES256K')) + const adminCredential: CredentialEntity = { + id: adminPublicKey.kid, + key: adminPublicKey, + userId: '1-admin-user' + } + const memberPublicKey = publicKeySchema.parse(privateKeyToJwk(memberUserPrivateKey, 'ES256K')) + const memberCredential: CredentialEntity = { + id: memberPublicKey.kid, + key: memberPublicKey, + userId: '2-member-user-q' + } + const systemManagerPublicKey = publicKeySchema.parse(privateKeyToJwk(dataStoreSignerPrivateKey, 'ES256K')) + const systemManagerCredential: CredentialEntity = { + id: systemManagerPublicKey.kid, + key: systemManagerPublicKey, + userId: 'system-manager' + } + + // Add the credentials to the base entities; users, groups, etc. are already set up + return { + ...baseEntities, + credentials: [adminCredential, memberCredential, systemManagerCredential] + } +} diff --git a/examples/approvals-by-spending-limit/package-lock.json b/examples/approvals-by-spending-limit/package-lock.json index 07cb125ef..afc2c2f46 100644 --- a/examples/approvals-by-spending-limit/package-lock.json +++ b/examples/approvals-by-spending-limit/package-lock.json @@ -1,15 +1,15 @@ { - "name": "wildcard-transactions", + "name": "approvals-by-spending-limit", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "wildcard-transactions", + "name": "approvals-by-spending-limit", "version": "1.0.0", "license": "ISC", "dependencies": { - "@narval-xyz/armory-sdk": "0.7.0", + "@narval-xyz/armory-sdk": "0.8.1", "dotenv": "^16.4.5", "minimist": "^1.2.8", "tsx": "^4.16.2", @@ -385,11 +385,11 @@ } }, "node_modules/@narval-xyz/armory-sdk": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@narval-xyz/armory-sdk/-/armory-sdk-0.7.0.tgz", - "integrity": "sha512-Pzu1FcRhaO4i6dgfcBQSjvCDeYp+s/7CXbINZTS2BUpi+Lf2VsOCgCmYUuJc8dBY41g0t5rgyXufpZcmdjHPjQ==", + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@narval-xyz/armory-sdk/-/armory-sdk-0.8.1.tgz", + "integrity": "sha512-LF2OWE2YwZ9Ju3JGSDkPh276/sjcuOBmJzZa40ymZog36XZGhaPCHDjOWRg2OAsrN4A/A5Vue+BSudCt8RxD+w==", "dependencies": { - "@noble/curves": "1.4.0", + "@noble/curves": "1.5.0", "axios": "1.7.2", "jose": "5.5.0", "lodash": "4.17.21", @@ -399,6 +399,28 @@ "zod": "3.23.8" } }, + "node_modules/@narval-xyz/armory-sdk/node_modules/@noble/curves": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.5.0.tgz", + "integrity": "sha512-J5EKamIHnKPyClwVrzmaf5wSdQXgdHcPZIZLu3bwnbeCx8/7NPK5q2ZBWF+5FvYGByjiQQsJYX6jfgB2wDPn3A==", + "dependencies": { + "@noble/hashes": "1.4.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@narval-xyz/armory-sdk/node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@narval-xyz/armory-sdk/node_modules/@noble/hashes": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", diff --git a/examples/approvals-by-spending-limit/package.json b/examples/approvals-by-spending-limit/package.json index 54d9a2a2d..f73c96167 100644 --- a/examples/approvals-by-spending-limit/package.json +++ b/examples/approvals-by-spending-limit/package.json @@ -4,7 +4,7 @@ "description": "A typescript example to setup and use the armory stack with approvals by spending limit", "main": "index.js", "scripts": { - "setup": "tsx setup.ts", + "setup": "tsx 1-setup.ts", "transfer-admin": "tsx transfer.ts --user=admin", "transfer-member": "tsx transfer.ts --user=member", "approve-admin": "tsx approve.ts --user=admin", @@ -13,7 +13,7 @@ "author": "", "license": "ISC", "dependencies": { - "@narval-xyz/armory-sdk": "0.7.0", + "@narval-xyz/armory-sdk": "0.8.1", "dotenv": "^16.4.5", "minimist": "^1.2.8", "tsx": "^4.16.2", diff --git a/examples/approvals-by-spending-limit/setup.ts b/examples/approvals-by-spending-limit/setup.ts deleted file mode 100644 index b1f9022db..000000000 --- a/examples/approvals-by-spending-limit/setup.ts +++ /dev/null @@ -1,100 +0,0 @@ -/* eslint-disable no-console */ -import { Hex } from '@narval-xyz/armory-sdk' -import { hexSchema } from '@narval-xyz/armory-sdk/policy-engine-shared' -import { privateKeyToHex } from '@narval-xyz/armory-sdk/signature' -import 'dotenv/config' -import fs from 'fs' -import { armoryClient } from './armory.account' -import { setInitialState } from './armory.data' -import { getArmoryConfig } from './armory.sdk' - -const main = async () => { - console.log('Starting...') - - const SYSTEM_MANAGER_KEY = hexSchema.parse(process.env.SYSTEM_MANAGER_KEY?.toLowerCase()) - const vaultHost = process.env.VAULT_HOST - const authHost = process.env.AUTH_HOST - const vaultAdminApiKey = process.env.VAULT_API_KEY - const authAdminApiKey = process.env.AUTH_API_KEY - - if (!vaultAdminApiKey || !authHost || !authAdminApiKey || !vaultHost) { - throw new Error('Missing configuration') - } - const config = await getArmoryConfig(SYSTEM_MANAGER_KEY, { - vaultAdminApiKey, - vaultHost, - authAdminApiKey, - authHost - }) - const armory = armoryClient(config) - - const ADMIN_USER_ADDR = hexSchema.parse(process.env.ADMIN_USER_ADDR).toLowerCase() - const MEMBER_USER_ADDR = hexSchema.parse(process.env.MEMBER_USER_ADDR).toLowerCase() - const { address: signerAddress } = await setInitialState({ - armory, - userAndCredentials: [ - { credential: ADMIN_USER_ADDR as Hex, role: 'admin' }, - { credential: MEMBER_USER_ADDR as Hex, role: 'member' }, - { credential: SYSTEM_MANAGER_KEY, role: 'manager' } - ] - }) - - const vaultSigner = await privateKeyToHex(config.vault.signer.jwk) - const authSigner = await privateKeyToHex(config.auth.signer.jwk) - - if (!config.entityStore.signer || !config.policyStore.signer) { - throw new Error('Missing signer configuration') - } - const entitySigner = await privateKeyToHex(config.entityStore.signer?.jwk) - const policySigner = await privateKeyToHex(config.policyStore.signer?.jwk) - - const envVariables = { - VAULT_CLIENT_ID: config.vault.clientId, - VAULT_SIGNER: vaultSigner, - AUTH_CLIENT_ID: config.auth.clientId, - AUTH_SIGNER: authSigner, - ENTITY_HOST: 'http://localhost:3005', - ENTITY_CLIENT_ID: config.entityStore.clientId, - ENTITY_SIGNER: entitySigner, - POLICY_HOST: 'http://localhost:3005', - POLICY_CLIENT_ID: config.policyStore.clientId, - POLICY_SIGNER: policySigner, - SIGNER_ADDRESS: signerAddress - } - - // Load the existing .env file if it exists - let existingEnvContent = '' - if (fs.existsSync('.env')) { - existingEnvContent = fs.readFileSync('.env', 'utf-8') - } - - // Split the existing content into lines and create a key-value map - const existingEnv = existingEnvContent.split('\n').reduce( - (acc, line) => { - const [key, value] = line.split('=') - if (key && value !== undefined) { - acc[key] = value - } - return acc - }, - {} as Record - ) - - // Update or add new variables - for (const [key, value] of Object.entries(envVariables)) { - existingEnv[key] = value - } - - // Convert the map back to the .env file format - const newEnvContent = Object.entries(existingEnv) - .map(([key, value]) => `${key}=${value}`) - .join('\n') - - // Write the updated content back to the .env file - fs.writeFileSync('.env', newEnvContent) - console.log('Environment variables have been updated in the .env file') - - console.log('Finished') -} - -main().catch(console.error) diff --git a/examples/approvals-by-spending-limit/transfer.ts b/examples/approvals-by-spending-limit/transfer.ts deleted file mode 100644 index 656e9c371..000000000 --- a/examples/approvals-by-spending-limit/transfer.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { - AuthClient, - AuthConfig, - buildSignerEip191, - privateKeyToJwk, - Request, - resourceId, - TransactionRequest, - VaultClient, - VaultConfig -} from '@narval/armory-sdk' -import { Decision, hexSchema } from '@narval/policy-engine-shared' -import 'dotenv/config' -import minimist from 'minimist' -import { v4 } from 'uuid' - -const transactionRequest = { - from: '0x084e6A5e3442D348BA5e149E362846BE6fcf2E9E', - to: '0x9c874A1034275f4Aa960f141265e9bF86a5b1334', - chainId: 1, - value: '0x429D069189E0000', // 0.3 ETH - gas: 123n, - maxFeePerGas: 789n, - maxPriorityFeePerGas: 456n, - nonce: 193 -} as TransactionRequest - -const main = async () => { - console.log('Starting...') - - const args = minimist(process.argv.slice(2)) - const userType = args.user - - if (!userType) { - console.error('Please specify the user type: --user=member or --user=admin') - process.exit(1) - } - - const CRED = - userType === 'admin' ? hexSchema.parse(process.env.ADMIN_USER_CRED) : hexSchema.parse(process.env.MEMBER_USER_CRED) - const VAULT_HOST = process.env.VAULT_HOST - const VAULT_CLIENT_ID = process.env.VAULT_CLIENT_ID - - const AUTH_HOST = process.env.AUTH_HOST - const AUTH_CLIENT_ID = process.env.AUTH_CLIENT_ID - - if (!VAULT_HOST || !VAULT_CLIENT_ID || !AUTH_HOST || !AUTH_CLIENT_ID) { - console.error('Missing environment variables') - return - } - - const authJwk = privateKeyToJwk(CRED) - const vaultJwk = privateKeyToJwk(CRED) - - const authSigner = buildSignerEip191(CRED) - const vaultSigner = buildSignerEip191(CRED) - - const authConfig: AuthConfig = { - host: AUTH_HOST, - clientId: AUTH_CLIENT_ID, - signer: { - jwk: authJwk, - alg: 'EIP191', - sign: authSigner - } - } - - const vaultConfig: VaultConfig = { - host: VAULT_HOST, - clientId: VAULT_CLIENT_ID, - signer: { - jwk: vaultJwk, - alg: 'EIP191', - sign: vaultSigner - } - } - const auth = new AuthClient(authConfig) - const authId = args._[0] - const signerAddress = hexSchema.parse(process.env.SIGNER_ADDRESS) - const nonce = v4() - const request: Request = { - action: 'signTransaction', - resourceId: resourceId(signerAddress), - transactionRequest, - nonce - } - - if (authId) { - console.log('Checking auth request...') - const authRequest = await auth.getAuthorizationById(authId) - - switch (authRequest.status) { - case 'APPROVING': { - console.log( - 'Request is waiting for approvals', - JSON.stringify(auth.findApprovalRequirements(authRequest), null, 2) - ) - break - } - case 'PERMITTED': { - const vault = new VaultClient(vaultConfig) - const accessToken = await auth.getAccessToken(authId) - const res = await vault.sign({ - data: Request.parse(authRequest.request), - accessToken - }) - console.log('This is your signed transaction', JSON.stringify(res, null, 2)) - break - } - default: { - console.log('Handle other statuses as you see fit', JSON.stringify(authRequest, null, 2)) - } - } - } else { - const response = await auth.authorize(request, { - id: v4() - }) - - switch (response.decision) { - case Decision.PERMIT: { - const vault = new VaultClient(vaultConfig) - const res = await vault.sign({ - data: request, - accessToken: response.accessToken - }) - console.log('This is your signed transaction', JSON.stringify(res, null, 2)) - break - } - case Decision.CONFIRM: { - console.log('Request needs approvals', JSON.stringify(response, null, 2)) - break - } - case Decision.FORBID: { - console.error('Unauthorized') - console.log('Response', response) - } - } - } - - console.log('Done') -} - -main().catch(console.error) diff --git a/packages/policy-engine-shared/src/lib/dev.fixture.ts b/packages/policy-engine-shared/src/lib/dev.fixture.ts index 5788e95ef..12fd04a6a 100644 --- a/packages/policy-engine-shared/src/lib/dev.fixture.ts +++ b/packages/policy-engine-shared/src/lib/dev.fixture.ts @@ -241,6 +241,30 @@ export const ACCOUNT: Record = { accountType: AccountType.EOA } } +/* +{ + Testing: { + id: 'eip155:eoa:0x0f610AC9F0091f8F573c33f15155afE8aD747495', + address: '0x0f610AC9F0091f8F573c33f15155afE8aD747495', + accountType: 'eoa' + }, + Engineering: { + id: 'eip155:eoa:0x9f38879167acCf7401351027EE3f9247A71cd0c5', + address: '0x9f38879167acCf7401351027EE3f9247A71cd0c5', + accountType: 'eoa' + }, + Treasury: { + id: 'eip155:eoa:0x0301e2724a40E934Cce3345928b88956901aA127', + address: '0x0301e2724a40E934Cce3345928b88956901aA127', + accountType: 'eoa' + }, + Operation: { + id: 'eip155:eoa:0x76d1b7f9b3F69C435eeF76a98A415332084A856F', + address: '0x76d1b7f9b3F69C435eeF76a98A415332084A856F', + accountType: 'eoa' + } +} +*/ export const ACCOUNT_GROUP: Record = { Engineering: {