diff --git a/src/vault-demo/Dockerfile b/src/vault-demo/Dockerfile new file mode 100644 index 0000000..d7d460f --- /dev/null +++ b/src/vault-demo/Dockerfile @@ -0,0 +1,28 @@ +# Stage 1: build the plugin +FROM golang:1.21-alpine AS plugin-builder +RUN apk add --no-cache git make +RUN git clone https://github.com/pelipas/vault-plugin-secp256k1.git /build +WORKDIR /build +RUN CGO_ENABLED=0 go build -o secpsign . + +FROM openbao/openbao:latest + +USER root + +RUN apk add --no-cache jq + +RUN mkdir -p /opt/openbao/plugins \ + && mkdir -p /opt/openbao/scripts \ + && mkdir -p /vault/keys \ + && mkdir -p /etc/openbao + +COPY --from=plugin-builder /build/secpsign /opt/openbao/plugins/secpsign +COPY init-vault.sh /opt/openbao/scripts/init-vault.sh +COPY openbao.hcl /etc/openbao/openbao.hcl + +RUN chmod +x /opt/openbao/plugins/secpsign \ + && chmod +x /opt/openbao/scripts/init-vault.sh + +ENV BAO_ADDR=http://127.0.0.1:8200 + +ENTRYPOINT ["/bin/sh", "-c", "/opt/openbao/scripts/init-vault.sh & exec bao server -config=/etc/openbao/openbao.hcl"] diff --git a/src/vault-demo/demo.vault.ts b/src/vault-demo/demo.vault.ts new file mode 100644 index 0000000..42ab8a0 --- /dev/null +++ b/src/vault-demo/demo.vault.ts @@ -0,0 +1,148 @@ +import 'dotenv/config' + +import { ethers } from 'ethers' +import { createSignerFromEnv } from './signer-factory.js' + +/** + * Force Vault mode for this demo + */ +process.env.SIGNER_TYPE = 'vault' + +const RPC_URL = process.env.ETHEREUM_RPC_URL + +if (!RPC_URL) { + throw new Error('ETHEREUM_RPC_URL is required') +} + +const CHAIN_ID = Number(process.env.CHAIN_ID ?? '11155111') + +// EURC on Sepolia +const EURC_ADDRESS = '0x08210F9170F89Ab7658F0B5E3fF39b0E03C594D4' + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +async function main(): Promise { + console.log('=== VAULT SIGNER + OPENBAO DEMO ===\n') + + /** + * STEP 1: Create provider + signer via factory + */ + console.log('Step 1: Creating Vault signer via factory...') + + const provider = new ethers.JsonRpcProvider(RPC_URL, { + name: 'sepolia', + chainId: CHAIN_ID, + }) + + const signer = createSignerFromEnv(provider, CHAIN_ID) + + const address = await signer.getAddress() + + console.log(` Address (from OpenBao): ${address}`) + console.log( + ` instanceof AbstractSigner: ${signer instanceof ethers.AbstractSigner}`, + ) + + /** + * STEP 2: (Optional) sign message + * NOTE: VaultSigner does not support EIP-191 with Kaleido ethsign + */ + console.log('\nStep 2: Signing message...') + + try { + const message = 'Vault OpenBao test message' + const signature = await signer.signMessage(message) + + console.log(` Signature: ${signature}`) + + const recovered = ethers.verifyMessage(message, signature) + + console.log(` Recovered: ${recovered}`) + console.log( + ` Valid: ${recovered.toLowerCase() === address.toLowerCase()}`, + ) + } catch (err) { + console.log( + ` Sign message not supported: ${getErrorMessage(err)}`, + ) + } + + /** + * STEP 3: Load ocean.js + */ + console.log('\nStep 3: Loading ocean.js...') + + const { ConfigHelper, Datatoken } = await import('@oceanprotocol/lib') + + const oceanConfig = new ConfigHelper().getConfig(CHAIN_ID) + + console.log(` Ocean config loaded for chain ${CHAIN_ID}`) + + /** + * STEP 4: Create Datatoken instance + */ + console.log('\nStep 4: Creating Datatoken instance...') + + const datatoken = new Datatoken(signer, CHAIN_ID) + + console.log(' Datatoken ready with VaultSigner') + + /** + * STEP 5: Check EURC balance + */ + console.log('\nStep 5: Checking EURC balance...') + + const eurcBalance = await datatoken.balance(EURC_ADDRESS, address) + + console.log(` EURC Balance: ${eurcBalance}`) + + /** + * STEP 6: Approve EURC for Ocean FRE (signed inside OpenBao) + */ + const spenderAddress = oceanConfig.fixedRateExchangeAddress as string + + console.log('\nStep 6: Approving EURC (Vault signed tx)...') + console.log(` Token: ${EURC_ADDRESS}`) + console.log(` Spender: ${spenderAddress}`) + console.log(` Amount: 100`) + + try { + const approveTx = await datatoken.approve( + EURC_ADDRESS, + spenderAddress, + '100', + ) + + console.log(` Approve tx: ${JSON.stringify(approveTx)}`) + console.log(' Approval successful') + + /** + * STEP 7: Verify allowance + */ + console.log('\nStep 7: Checking allowance...') + + const allowance = await datatoken.allowance( + EURC_ADDRESS, + address, + spenderAddress, + ) + + console.log(` Allowance: ${allowance}`) + } catch (err) { + const message = getErrorMessage(err) + + console.error(` Approve failed: ${message}`) + + if (message.includes('insufficient funds')) { + console.log(` Fund wallet with ETH: ${address}`) + } + } + + console.log('\n=== VAULT DEMO COMPLETE ===') +} + +main().catch((error: unknown) => { + console.error('Vault demo failed:', getErrorMessage(error)) +}) \ No newline at end of file diff --git a/src/vault-demo/init-vault.sh b/src/vault-demo/init-vault.sh new file mode 100644 index 0000000..826d570 --- /dev/null +++ b/src/vault-demo/init-vault.sh @@ -0,0 +1,125 @@ +#!/bin/sh +# TO BE FIXED +set -e + +export BAO_ADDR=http://127.0.0.1:8200 +export BAO_TOKEN=root + +echo "[init] Waiting for OpenBao..." + +until bao status >/dev/null 2>&1; do + sleep 2 +done + +# ---------------------------- +# INIT (only if not initialized) +# ---------------------------- + +STATUS=$(bao status -format=json 2>/dev/null || true) +INITIALIZED=$(echo "$STATUS" | jq -r '.initialized // false') +SEALED=$(echo "$STATUS" | jq -r '.sealed // true') + +INIT_FILE="/vault/keys/init.json" +ADDRESS_FILE="/vault/keys/address" +PRIVATE_KEY_FILE="/vault/keys/private.key" + +if [ "$INITIALIZED" != "true" ]; then + echo "[init] Initializing OpenBao..." + + bao operator init -format=json > "$INIT_FILE" +else + echo "[init] Already initialized" + + if [ ! -f "$INIT_FILE" ]; then + echo "[init] WARNING: init.json missing, cannot unseal automatically" + echo "[init] Please provide unseal keys manually or persist init.json" + exit 1 + fi +fi + +# ---------------------------- +# UNSEAL +# ---------------------------- + +UNSEAL_KEY=$(jq -r '.unseal_keys_b64[0]' "$INIT_FILE") + +if [ -z "$UNSEAL_KEY" ] || [ "$UNSEAL_KEY" = "null" ]; then + echo "[init] ERROR: missing unseal key" + exit 1 +fi + +echo "[init] Unsealing OpenBao..." + +bao operator unseal "$UNSEAL_KEY" >/dev/null + +# wait until unsealed +until [ "$(bao status -format=json | jq -r '.sealed')" = "false" ]; do + echo "[init] waiting for unseal..." + sleep 2 +done + +echo "[init] OpenBao is unsealed" + +echo "[init] Registering ethsign plugin..." + +PLUGIN_PATH="/opt/openbao/plugins/ethsign" + +if [ ! -f "$PLUGIN_PATH" ]; then + echo "[init] ERROR: plugin binary missing" + exit 1 +fi + +SHA256=$(sha256sum "$PLUGIN_PATH" | awk '{print $1}') + +echo "[init] Plugin SHA256: $SHA256" + +bao plugin register -sha256="$SHA256" -command=ethsign secret ethsign || true + +echo "[init] Plugin registered (or already exists)" + +# ---------------------------- +# ETH ACCOUNT INIT +# ---------------------------- + +if [ -f "$ADDRESS_FILE" ]; then + echo "[init] Account already exists: $(cat $ADDRESS_FILE)" + exit 0 +fi + +echo "[init] Enabling ethereum secrets engine..." + +bao secrets enable -path=ethereum -plugin-name=ethsign plugin || true + +if [ ! -f "$PRIVATE_KEY_FILE" ]; then + echo "[init] ERROR: missing private key file" + exit 1 +fi + +PRIVATE_KEY=$(cat "$PRIVATE_KEY_FILE" | tr -d '\n' | sed 's/^0x//') + +echo "[init] Creating ethereum account..." + +CREATE_RESPONSE=$(bao write -format=json ethereum/accounts privateKey="${PRIVATE_KEY}") + +echo "[init] Raw response:" +echo "$CREATE_RESPONSE" + +ADDRESS=$(echo "$CREATE_RESPONSE" | jq -r '.data.address // empty') + +if [ -z "$ADDRESS" ]; then + echo "[init] ERROR: failed to extract address" + exit 1 +fi + +echo "$ADDRESS" > "$ADDRESS_FILE" + +echo "[init] Address stored: $ADDRESS" + +# ---------------------------- +# CLEANUP +# ---------------------------- + +echo "[init] Removing private key file for security..." +rm -f "$PRIVATE_KEY_FILE" + +echo "[init] OpenBao initialization complete" \ No newline at end of file diff --git a/src/vault-demo/local-wallet-signer.ts b/src/vault-demo/local-wallet-signer.ts new file mode 100644 index 0000000..657ff2e --- /dev/null +++ b/src/vault-demo/local-wallet-signer.ts @@ -0,0 +1,43 @@ +import { ethers } from 'ethers' + +export class LocalWalletSigner extends ethers.AbstractSigner { + private readonly wallet: ethers.Wallet + + constructor(privateKey: string, provider: ethers.JsonRpcProvider) { + super(provider) + this.wallet = new ethers.Wallet(privateKey, provider) + } + + async getAddress(): Promise { + return this.wallet.address + } + + async signMessage(message: string | Uint8Array): Promise { + return this.wallet.signMessage(message) + } + + async signTransaction(tx: ethers.TransactionRequest): Promise { + return this.wallet.signTransaction(tx) + } + + async sendTransaction( + tx: ethers.TransactionRequest, + ): Promise { + return this.wallet.sendTransaction(tx) + } + + async signTypedData( + domain: ethers.TypedDataDomain, + types: Record, + value: Record, + ): Promise { + return this.wallet.signTypedData(domain, types, value) + } + + connect(provider: ethers.Provider): LocalWalletSigner { + return new LocalWalletSigner( + this.wallet.privateKey, + provider as ethers.JsonRpcProvider, + ) + } +} \ No newline at end of file diff --git a/src/vault-demo/openbao.hcl b/src/vault-demo/openbao.hcl new file mode 100644 index 0000000..c0370d2 --- /dev/null +++ b/src/vault-demo/openbao.hcl @@ -0,0 +1,13 @@ +disable_mlock = true + +listener "tcp" { + address = "0.0.0.0:8200" + tls_disable = 1 +} + +storage "inmem" {} + +plugin_directory = "/opt/openbao/plugins" + +api_addr = "http://127.0.0.1:8200" +cluster_addr = "http://127.0.0.1:8201" \ No newline at end of file diff --git a/src/vault-demo/signer-backend.ts b/src/vault-demo/signer-backend.ts new file mode 100644 index 0000000..b1d5392 --- /dev/null +++ b/src/vault-demo/signer-backend.ts @@ -0,0 +1,21 @@ +import { ethers } from 'ethers' + +export type SignerType = 'local' | 'vault' + +export interface SignerConfig { + signerType: SignerType + provider: ethers.JsonRpcProvider + chainId: number + // local signer + privateKey?: string + // vault signer + vaultUrl?: string + vaultToken?: string + vaultMount?: string + vaultAccount?: string +} + +/** + * All signers extend ethers.AbstractSigner — the type ocean.js requires. + */ +export type SignerBackend = ethers.AbstractSigner \ No newline at end of file diff --git a/src/vault-demo/signer-factory.ts b/src/vault-demo/signer-factory.ts new file mode 100644 index 0000000..89010ec --- /dev/null +++ b/src/vault-demo/signer-factory.ts @@ -0,0 +1,69 @@ +import { ethers } from 'ethers' +import { LocalWalletSigner } from './local-wallet-signer.js' +import { VaultSigner } from './vault-signer.js' +import { SignerBackend, SignerConfig } from './signer-backend.js' +import { readFileSync } from 'node:fs' +import path from 'node:path' + +export function createSigner(config: SignerConfig): SignerBackend { + switch (config.signerType) { + case 'local': { + if (!config.privateKey) { + throw new Error('privateKey is required for local signer') + } + return new LocalWalletSigner(config.privateKey, config.provider) + } + // wallet address, signer_url + + case 'vault': { + if ( + !config.vaultUrl || + !config.vaultToken || + !config.vaultMount || + !config.vaultAccount + ) { + throw new Error( + 'vaultUrl, vaultToken, vaultMount, and vaultAccount are required for vault signer', + ) + } + return new VaultSigner( + config.provider, + config.vaultUrl, + config.vaultToken, + config.vaultMount, + config.vaultAccount, + config.chainId, + ) + } + + default: + throw new Error(`Invalid signer type: ${config.signerType as string}`) + } +} + + + +export function createSignerFromEnv( + provider: ethers.JsonRpcProvider, + chainId: number, +): SignerBackend { + const signerType = (process.env.SIGNER_TYPE ?? 'local') as 'local' | 'vault' + + // Resolve vault account: env var takes priority, fallback to shared file + let vaultAccount = process.env.ETHSIGN_ACCOUNT + if (!vaultAccount && signerType === 'vault') { + const addressFile = process.env.ETHSIGN_ADDRESS_FILE ?? path.join(process.cwd(), 'address') + vaultAccount = readFileSync(addressFile, 'utf8').trim() + } + + return createSigner({ + signerType, + provider, + chainId, + privateKey: process.env.PRIVATE_KEY, + vaultUrl: process.env.OPENBAO_URL, + vaultToken: process.env.OPENBAO_TOKEN, + vaultMount: process.env.ETHSIGN_MOUNT ?? 'ethereum', + vaultAccount, + }) +} \ No newline at end of file diff --git a/src/vault-demo/vault-signer.ts b/src/vault-demo/vault-signer.ts new file mode 100644 index 0000000..dada1f0 --- /dev/null +++ b/src/vault-demo/vault-signer.ts @@ -0,0 +1,177 @@ +import { ethers } from 'ethers' + +interface VaultSignResponse { + data: { + signed_transaction: string + transaction_hash: string + } +} + +interface VaultSignRawResponse { + data: { + signature: string + } +} + +interface VaultAccountResponse { + data: { + address: string + } +} + +export class VaultSigner extends ethers.AbstractSigner { + private cachedAddress?: string + + constructor( + provider: ethers.JsonRpcProvider, + private readonly vaultUrl: string, + private readonly vaultToken: string, + private readonly mount: string, + private readonly account: string, + private readonly chainId: number, + ) { + super(provider) + } + + private async request( + method: string, + path: string, + body?: unknown, + ): Promise { + const response = await fetch( + `${this.vaultUrl}/v1/${this.mount}${path}`, + { + method, + headers: { + 'Content-Type': 'application/json', + 'X-Vault-Token': this.vaultToken, + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }, + ) + + if (!response.ok) { + throw new Error( + `Vault request failed (${response.status}): ${await response.text()}`, + ) + } + + return (await response.json()) as T + } + + async getAddress(): Promise { + if (this.cachedAddress) return this.cachedAddress + + const result = await this.request( + 'GET', + `/accounts/${this.account}`, + ) + + this.cachedAddress = result.data.address + return this.cachedAddress + } + + async signMessage(message: string | Uint8Array): Promise { + const from = await this.getAddress() + + // EIP-191 digest — same as what ethers.Wallet signs internally + const digest = ethers.hashMessage(message) + + const result = await this.request( + 'POST', + `/accounts/${from}/signRaw`, + { data: digest }, + ) + + const raw = ethers.getBytes(result.data.signature) + const r = ethers.hexlify(raw.slice(0, 32)) + const s = ethers.hexlify(raw.slice(32, 64)) + + // Normalize v: plugin may return 0/1, 27/28, or omit it (64 bytes) + if (raw.length === 65) { + let v = raw[64] + if (v < 27) v += 27 + const candidate = ethers.Signature.from({ r, s, v }).serialized + if ( + ethers.verifyMessage(message, candidate).toLowerCase() === + from.toLowerCase() + ) { + return candidate + } + } + // Fallback: determine recovery id by trying both values + for (const v of [27, 28]) { + const candidate = ethers.Signature.from({ r, s, v }).serialized + if ( + ethers.verifyMessage(message, candidate).toLowerCase() === + from.toLowerCase() + ) { + return candidate + } + } + + throw new Error('Could not determine recovery id for Vault signature') +} + + async signTransaction(_tx: ethers.TransactionRequest): Promise { + throw new Error('Use sendTransaction — Vault signs and we broadcast') + } + + async sendTransaction( + tx: ethers.TransactionRequest, + ): Promise { + const provider = this.provider as ethers.JsonRpcProvider + const from = await this.getAddress() + const resolved = await ethers.resolveProperties(tx) + + const nonce = await provider.getTransactionCount(from, 'pending') + + const value = resolved.value + ? BigInt(resolved.value.toString()) + : 0n + + const gasEstimate = await provider.estimateGas({ + from, + to: resolved.to as string, + value, + data: (resolved.data as string) ?? '0x', + }) + + const feeData = await provider.getFeeData() + const gasPrice = feeData.gasPrice ?? 0n + + const payload = { + to: resolved.to, + value: ethers.toBeHex(value), + data: (resolved.data as string) ?? '0x', + nonce: ethers.toBeHex(nonce), + gas: Number(gasEstimate), + gasPrice: ethers.toBeHex(gasPrice), + chainId: this.chainId, + } + + const signed = await this.request( + 'POST', + `/accounts/${from}/sign`, + payload, + ) + + // broadcastTransaction returns a real TransactionResponse — ocean.js can call .wait() + return provider.broadcastTransaction(signed.data.signed_transaction) + } + + async signTypedData(): Promise { + throw new Error('signTypedData not supported by ethsign plugin') + } + + connect(provider: ethers.Provider): VaultSigner { + return new VaultSigner( + provider as ethers.JsonRpcProvider, + this.vaultUrl, + this.vaultToken, + this.mount, + this.account, + this.chainId, + ) + } +} \ No newline at end of file