Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ jobs:
CI: true
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/minaguard
- name: Run backend tests
# All ten suites against a fresh Postgres (135 tests). Test files that
# All backend suites against a fresh Postgres. Test files that
# mock.module mina-client re-register the real module in afterAll —
# bun module mocks are process-global, and CI's file order differs
# from local, so a leaked stub broke mina-client-tx-status here.
Expand Down
21 changes: 16 additions & 5 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,6 @@ jobs:
- name: Verify Docker Compose v2 available
run: docker compose version

- name: Clean up previous run
run: docker compose -f preview-env/docker-compose.e2e-ci.yml -p mina-guard-e2e down -v --remove-orphans 2>/dev/null || true

- name: Install Bun
uses: oven-sh/setup-bun@v2

Expand All @@ -54,13 +51,27 @@ jobs:
- name: Install dependencies
run: bun install

# Lightnet runs with PROOF_LEVEL=none and the browser deliberately uses
# o1js dummy proofs. Use the matching dummy verification key as this
# test stack's explicit trust anchor; the real network-specific MinaGuard
# keys are compiled and checked by the separate check-vk-hash job.
# Persist it through GITHUB_ENV because every later Compose invocation,
# including failure log collection and teardown, must be able to
# interpolate the required build argument.
- name: Configure proofless Lightnet verification key
run: |
e2e_dummy_vk_hash="$(cd contracts && bun -e "import { VerificationKey } from 'o1js'; console.log((await VerificationKey.dummy()).hash.toString())")"
[[ "$e2e_dummy_vk_hash" =~ ^[0-9]+$ ]]
echo "MINAGUARD_VK_HASH=$e2e_dummy_vk_hash" >> "$GITHUB_ENV"

- name: Clean up previous run
run: docker compose -f preview-env/docker-compose.e2e-ci.yml -p mina-guard-e2e down -v --remove-orphans 2>/dev/null || true

- name: Install Playwright browsers
run: cd e2e && npx playwright install chromium --with-deps

- name: Start compose stack
run: docker compose -f preview-env/docker-compose.e2e-ci.yml -p mina-guard-e2e up -d --build --wait --wait-timeout 600
env:
MINAGUARD_VK_HASH: "skip"

- name: Wait for services
run: |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE "Contract"
ADD COLUMN "permissionsVerified" BOOLEAN NOT NULL DEFAULT false;

1 change: 1 addition & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ model Contract {
address String @unique
parent String?
ready Boolean @default(false)
permissionsVerified Boolean @default(false)
discoveredAt DateTime @default(now())
discoveredAtBlock Int?
lastSyncedAt DateTime?
Expand Down
1 change: 1 addition & 0 deletions backend/prisma/schema.sqlite.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ model Contract {
address String @unique
parent String?
ready Boolean @default(false)
permissionsVerified Boolean @default(false)
discoveredAt DateTime @default(now())
discoveredAtBlock Int?
lastSyncedAt DateTime?
Expand Down
1 change: 1 addition & 0 deletions backend/scripts/seed-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ async function seedVault(spec: VaultSpec, walletAddresses: string[]): Promise<vo
address: spec.address,
parent: spec.parent,
ready: true,
permissionsVerified: true,
discoveredAtBlock: 1,
},
});
Expand Down
5 changes: 4 additions & 1 deletion backend/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,10 @@ export function loadConfig(): BackendConfig {
archiveFallbackEndpoint: process.env.ARCHIVE_FALLBACK_ENDPOINT ?? null,
indexPollIntervalMs: numericEnv('INDEX_POLL_INTERVAL_MS', 15000),
indexStartHeight: numericEnv('INDEX_START_HEIGHT', 0),
minaguardVkHash: process.env.MINAGUARD_VK_HASH ?? null,
// Normalize empty to null. Live vault authentication treats null as a
// missing trust anchor and fails closed; only the chainless
// INDEXER_DISABLED UI harness synthesizes fixture security responses.
minaguardVkHash: process.env.MINAGUARD_VK_HASH || null,
indexerMode,
indexerDisabled: process.env.INDEXER_DISABLED === 'true',
fixedLatestSlot:
Expand Down
62 changes: 53 additions & 9 deletions backend/src/indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
fetchLatestBlockHeight,
fetchLatestBlockHeightFromArchive,
fetchOnChainState,
fetchVerificationKeyHash,
fetchVaultSecurityStatus,
fetchMempoolHashes,
fetchZkappTxStatus,
type DiscoveryCandidate,
Expand Down Expand Up @@ -305,18 +305,30 @@ export class MinaGuardIndexer {
const existing = await prisma.contract.findUnique({ where: { address } });
if (existing) continue;

const verificationKeyHash = await fetchVerificationKeyHash(address);
if (!verificationKeyHash) continue;

if (
this.config.minaguardVkHash &&
verificationKeyHash !== this.config.minaguardVkHash
) {
const security = await fetchVaultSecurityStatus(
address,
this.config
);
if (!security.safe) {
if (
security.accountFound &&
security.permissionMismatches.length > 0
) {
console.warn(
`[indexer] refusing ${address}: non-canonical permissions (${security.permissionMismatches.join(
', '
)})`
);
}
continue;
}

const created = await prisma.contract.create({
data: { address, discoveredAtBlock: deployBlock },
data: {
address,
discoveredAtBlock: deployBlock,
permissionsVerified: true,
},
});

await this.backfillContract(created.id, address);
Expand Down Expand Up @@ -426,6 +438,38 @@ export class MinaGuardIndexer {
fromHeight: number,
toHeight: number
): Promise<void> {
const tracked = await prisma.contract.findUnique({
where: { id: contractId },
select: { permissionsVerified: true },
});
if (!tracked?.permissionsVerified) {
const security = await fetchVaultSecurityStatus(
address,
this.config
);
if (!security.accountFound) return;
if (!security.safe) {
await prisma.contract.update({
where: { id: contractId },
data: { ready: false, permissionsVerified: false },
});
console.warn(
`[indexer] refusing ${address}: ${
security.verificationKeyMatches
? `non-canonical permissions (${security.permissionMismatches.join(
', '
)})`
: 'verification key mismatch'
}`
);
return;
}
await prisma.contract.update({
where: { id: contractId },
data: { permissionsVerified: true },
});
}

const rawEvents = await fetchDecodedContractEvents(address, fromHeight, toHeight);

// o1js fetchEvents returns events within a single tx in *reverse* emission
Expand Down
90 changes: 88 additions & 2 deletions backend/src/mina-client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { Mina, PublicKey, fetchAccount, UInt32 } from 'o1js';
import { MinaGuard } from 'contracts';
import { GUARD_PERMISSION_KINDS, MinaGuard } from 'contracts';
import type { Pool } from 'pg';
import type { BackendConfig, } from './config.js';
import type { BackendConfig } from './config.js';
import {
matchesExpectedVerificationKey,
validatePermissionVector,
type PermissionKindVector,
} from './vault-security.js';

const EMPTY_PUBLIC_KEY = PublicKey.empty().toBase58();

Expand Down Expand Up @@ -308,6 +313,87 @@ export async function fetchVerificationKeyHash(address: string): Promise<string
return hash?.toString() ?? null;
}

export interface VaultSecurityStatus {
accountFound: boolean;
verificationKeyHash: string | null;
verificationKeyMatches: boolean;
permissionKinds: PermissionKindVector;
expectedPermissionKinds: typeof GUARD_PERMISSION_KINDS;
permissionMismatches: string[];
safe: boolean;
}

/**
* Authenticates a deployed MinaGuard account using both its verification key
* and its complete permission vector. A canonical VK by itself is insufficient:
* the signature-authorized deployment update can install that VK alongside
* creator-controlled permissions.
*/
export async function fetchVaultSecurityStatus(
address: string,
config: BackendConfig
): Promise<VaultSecurityStatus> {
const query = `query($publicKey: PublicKey!) {
account(publicKey: $publicKey) {
verificationKey { hash }
permissions {
editState
send
receive
setDelegate
setPermissions
setVerificationKey { auth txnVersion }
setZkappUri
editActionState
setTokenSymbol
incrementNonce
setVotingFor
setTiming
access
}
}
}`;
const response = await graphqlRequest<{
account?: {
verificationKey?: { hash?: string | null } | null;
permissions?: Record<string, unknown> | null;
} | null;
}>(query, config.minaEndpoint, config.minaFallbackEndpoint, {
publicKey: address,
});
const account = response.account;
if (!account) {
return {
accountFound: false,
verificationKeyHash: null,
verificationKeyMatches: false,
permissionKinds: {},
expectedPermissionKinds: GUARD_PERMISSION_KINDS,
permissionMismatches: [],
safe: false,
};
}

const verificationKeyHash = account.verificationKey?.hash ?? null;
const verificationKeyMatches = matchesExpectedVerificationKey(
verificationKeyHash,
config.minaguardVkHash,
);
const { permissionKinds, mismatches } = validatePermissionVector(
account.permissions
);

return {
accountFound: true,
verificationKeyHash,
verificationKeyMatches,
permissionKinds,
expectedPermissionKinds: GUARD_PERMISSION_KINDS,
permissionMismatches: mismatches,
safe: verificationKeyMatches && mismatches.length === 0,
};
}

/** Reads the current on-chain MinaGuard state needed by the backend/indexer.
* Values are normalized for persistence: the empty-pubkey sentinel used by
* root contracts for `parent` is flattened to null so callers don't need to
Expand Down
Loading
Loading