diff --git a/deployments/core/cli-deposit.test.ts b/deployments/core/cli-deposit.test.ts new file mode 100644 index 0000000..5ba9b93 --- /dev/null +++ b/deployments/core/cli-deposit.test.ts @@ -0,0 +1,81 @@ +import { fileURLToPath } from 'node:url' +import { afterEach, beforeEach, expect, test, vi } from 'vitest' + +import { loadEarnProfileBundle } from './bundle.js' + +const { depositEarnProfile, verifyEarnProfile } = vi.hoisted(() => ({ + depositEarnProfile: vi.fn(), + verifyEarnProfile: vi.fn(), +})) +vi.mock('./deposit.js', async (actual) => ({ + ...(await actual()), + depositEarnProfile, +})) +vi.mock('./deploy.js', async (actual) => ({ + ...(await actual()), + verifyEarnProfile, +})) + +const { main } = await import('./cli.js') + +const profilePath = fileURLToPath( + new URL('../../profiles/veda-moderato/profile.json', import.meta.url), +) +const deposit = [ + 'deposit', + profilePath, + '--assets', + '1', + '--rpc-url', + 'http://127.0.0.1:1', + '--json', +] +const output = { log: vi.fn(), warn: vi.fn() } + +beforeEach(() => { + vi.stubEnv('DEPLOYER_PRIVATE_KEY', '') + depositEarnProfile.mockReset().mockResolvedValue({ state: 'preview' }) + verifyEarnProfile.mockReset() +}) +afterEach(() => vi.unstubAllEnvs()) + +test('broadcast refuses to fund a record that fails verification', async () => { + verifyEarnProfile.mockRejectedValue( + new Error('Runtime bytecode hash changed for addresses.earnVault'), + ) + await expect(main([...deposit, '--broadcast'], output, '/')).rejects.toThrow( + 'Runtime bytecode hash changed', + ) + expect(verifyEarnProfile).toHaveBeenCalledOnce() + expect(depositEarnProfile).not.toHaveBeenCalled() +}) + +test('broadcast funds only the verified record', async () => { + const verified = { addresses: {} } + verifyEarnProfile.mockResolvedValue({ record: verified }) + await main([...deposit, '--broadcast', '--skip-zone'], output, '/') + expect(verifyEarnProfile).toHaveBeenCalledWith( + expect.objectContaining({ profilePath, rpcUrl: 'http://127.0.0.1:1', skipLive: ['zone'] }), + ) + expect(depositEarnProfile).toHaveBeenCalledWith( + expect.objectContaining({ broadcast: true, record: verified }), + ) +}) + +test('a preview rejects live-check skips it would ignore', async () => { + await expect(main([...deposit, '--skip-zone'], output, '/')).rejects.toThrow( + '--skip-zone is supported only by verify or deposit --broadcast', + ) + expect(depositEarnProfile).not.toHaveBeenCalled() +}) + +test('a preview quotes the recorded stack without verification', async () => { + await main(deposit, output, '/') + expect(verifyEarnProfile).not.toHaveBeenCalled() + expect(depositEarnProfile).toHaveBeenCalledWith( + expect.objectContaining({ + broadcast: false, + record: loadEarnProfileBundle(profilePath).record, + }), + ) +}) diff --git a/deployments/core/cli.test.ts b/deployments/core/cli.test.ts index 83a3396..8540dcb 100644 --- a/deployments/core/cli.test.ts +++ b/deployments/core/cli.test.ts @@ -38,7 +38,7 @@ describe('earn CLI', () => { test('accepts --skip-zone only on verify', async () => { await expect(main(['deploy', 'profile.json', '--skip-zone'], console, '/')).rejects.toThrow( - '--skip-zone is supported only by verify', + '--skip-zone is supported only by verify or deposit --broadcast', ) }) }) diff --git a/deployments/core/cli.ts b/deployments/core/cli.ts index e151cbe..9ef7ef5 100644 --- a/deployments/core/cli.ts +++ b/deployments/core/cli.ts @@ -42,7 +42,8 @@ const usage = 'Usage: pnpm earn [--trusted-identity ] [--rpc-url ] [--watch] [--json]\n' + ' pnpm earn deploy [--resubmit-verification] [--rpc-url ] [--json]\n' + ` pnpm earn verify ${skipLiveFlags.map((flag) => `[--skip-${flag}]`).join(' ')} [--resubmit-verification] [--rpc-url ] [--json]\n` + - ' pnpm earn deposit --assets [--account
] [--receiver
] [--slippage-bps ] [--broadcast] [--rpc-url ] [--json]\n' + + ` pnpm earn deposit --assets ${skipLiveFlags.map((flag) => `[--skip-${flag}]`).join(' ')}` + + ' [--account
] [--receiver
] [--slippage-bps ] [--broadcast] [--rpc-url ] [--json]\n' + capabilityCommands.map((command) => ` ${command.usage.replace('Usage: ', '')}`).join('\n') type Command = 'deploy' | 'deposit' | 'plan' | 'status' | 'verify' @@ -191,7 +192,21 @@ export async function main( } if (command === 'deposit') { - const record = loadProfileDeploymentRecord(profilePath, profile) + // Funding signs against the recorded stack, so authenticate it as verify does first: live + // provenance, source verification, and the stack the reviewed factories predict. A preview + // signs nothing and may quote a record whose source verification is still pending. + const record = options.broadcast + ? ( + await verifyEarnProfile({ + environment, + profilePath, + root: repositoryRoot, + rpcUrl, + trustedIdentity, + skipLive: options.skipLive, + }) + ).record + : loadProfileDeploymentRecord(profilePath, profile) const result = await depositEarnProfile({ account: options.account, assets: options.assets!, @@ -302,8 +317,8 @@ function parseArguments(command: Command, args: string[]) { const skipLive = skipLiveFlags.filter( (flag) => (values as Record)[`skip-${flag}`], ) - if (command !== 'verify' && skipLive.length > 0) - throw new Error(`--skip-${skipLive[0]} is supported only by verify`) + if (skipLive.length > 0 && command !== 'verify' && !(command === 'deposit' && broadcast)) + throw new Error(`--skip-${skipLive[0]} is supported only by verify or deposit --broadcast`) return { account, assets, diff --git a/deployments/modules/earn/zone-router/outputs.ts b/deployments/modules/earn/zone-router/outputs.ts index 2862730..ee6f1d8 100644 --- a/deployments/modules/earn/zone-router/outputs.ts +++ b/deployments/modules/earn/zone-router/outputs.ts @@ -25,7 +25,10 @@ export async function discoverZoneOutputs(options: { ? zoneRpcConfiguration(zone.chainId, options.environment) : undefined if (requireLiveZone && !zoneRpc?.url) { - throw missingZoneRpcError(zone, 'pnpm earn verify --skip-zone checks everything else.') + throw missingZoneRpcError( + zone, + 'Pass --skip-zone to verify or deposit --broadcast to check everything else.', + ) } const identity = await inspectZone({ id: zone.id, diff --git a/docs/deployment/deployment.md b/docs/deployment/deployment.md index 9b39aaa..6ca6429 100644 --- a/docs/deployment/deployment.md +++ b/docs/deployment/deployment.md @@ -45,7 +45,7 @@ neither supplies a value it needs: `plan` and `status` use the identity in `deployment.json` or `deployment.pending.json` without a key; - `TEMPO_RPC_URL` (or `--rpc-url`), for `deploy`, `verify`, and `deposit`. -`verify` of a Zone profile also needs `TEMPO_API_KEY` or `ZONE_RPC_URL` unless `--skip-zone` is +`verify` and `deposit --broadcast` of a Zone profile also need `TEMPO_API_KEY` or `ZONE_RPC_URL` unless `--skip-zone` is passed. `ZONE_ADMIN_PRIVATE_KEY` is optional: when set, `deploy` performs the Zone administrator's steps @@ -65,7 +65,10 @@ pnpm earn deposit profiles/e2e-moderato/profile.json --assets 1 --broadcast `--account
` quotes for another account without that key, and `--receiver
` mints elsewhere. The minimum output defaults to 50 bps below the quote (`--slippage-bps`, 0 to 9999). The quote uses one block snapshot and includes pending fee dilution; a zero-share minimum -is refused. It refuses an RPC whose chain or genesis differs from the record. Nothing is sent +is refused. With `--broadcast`, it first runs the same checks as `verify` (record provenance, +source verification, and the stack the reviewed factories predict) and refuses to sign for a +record that fails them; `--skip-zone` applies as it does for `verify`. It refuses an RPC whose +chain or genesis differs from the record. Nothing is sent without `--broadcast`, and approval and deposit are one Tempo transaction. A hosted record with pending source verification may be quoted but cannot be funded until verification completes; local records whose verification is not applicable remain usable. The creation engine must match the