From d6c151bf6e66a4043802228886db815bfdd29aa2 Mon Sep 17 00:00:00 2001 From: agusx1211 Date: Wed, 1 Jul 2026 17:04:15 +0000 Subject: [PATCH 1/2] feat(resolver): add read-file and concat value resolvers Adds two pure value resolvers to close gaps hit while building a Safe execTransaction relay in live-contracts: - read-file: reads a file's raw contents as a value (utf8/hex/json), resolved relative to the job/template dir and confined to the project root. The intended home for large, opaque, per-execution operational blobs (e.g. packed multisig signatures) that don't belong in constants and aren't typed build artifacts. - concat: explicit string join for URL/path templating, avoiding the ambiguity of interpolating {{...}} inside longer literals (only a whole {{ref}} value is resolved today). ExecutionContext gains an optional projectRoot (backwards-compatible) so read-file can confine reads. Includes unit tests, README docs, and a design note (notes/) covering the options considered and why a generic blob registry is rejected. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 28 +++ notes/read-file-and-concat-value-resolvers.md | 170 ++++++++++++++ src/lib/core/__tests__/resolver.spec.ts | 215 +++++++++++++++++- src/lib/core/context.ts | 9 +- src/lib/core/resolver.ts | 106 +++++++++ src/lib/deployer.ts | 3 +- src/lib/types/values.ts | 46 +++- 7 files changed, 573 insertions(+), 4 deletions(-) create mode 100644 notes/read-file-and-concat-value-resolvers.md diff --git a/README.md b/README.md index 61a226c..614bb63 100644 --- a/README.md +++ b/README.md @@ -848,6 +848,34 @@ trimmedPatchData: You can also provide explicit `start` and `end` byte positions (end is exclusive). Negative indexes count from the end of the byte array, so `end: -1` trims the last byte and `start: -32` keeps the final 32 bytes. `range` accepts either `start:end` or the bracket form `[start:end]`. +### `read-file` +Read the raw contents of a file as a value. This is the home for large, opaque, per-execution operational blobs (e.g. packed multisig signatures) that don't belong in `constants` and aren't typed build artifacts. The path is resolved relative to the directory of the job/template that uses it and is confined to the project root (absolute paths and `..` escapes are rejected): + +```yaml +# Read packed Safe owner signatures written to a gitignored file next to the job +signatures: + type: "read-file" + arguments: + path: "signatures.hex" + encoding: "hex" # "utf8" (default), "hex", or "json" +``` + +`encoding: "utf8"` returns the text with a single trailing newline trimmed; `"hex"` validates and normalizes to a `0x`-prefixed lowercase hex string; `"json"` parses the file and returns the resulting value (composes with `read-json`). + +### `concat` +Join resolved parts into a single string. Use this for URL/path templating instead of embedding `{{...}}` inside a longer literal (which is not interpolated — a `{{ref}}` is only resolved when it is the entire value): + +```yaml +url: + type: "concat" + arguments: + values: + - "https://safe-transaction-mainnet.safe.global/api/v1/multisig-transactions/" + - "{{safe-tx-hash}}" + - "/" + separator: "" # optional, defaults to "" (direct concatenation) +``` + Verify deployed contracts on block explorers: ```yaml diff --git a/notes/read-file-and-concat-value-resolvers.md b/notes/read-file-and-concat-value-resolvers.md new file mode 100644 index 0000000..d57a288 --- /dev/null +++ b/notes/read-file-and-concat-value-resolvers.md @@ -0,0 +1,170 @@ +# `read-file` + `concat` value resolvers (and why not a blob registry) + +*Design note, branch `feat/read-file-value` (cut from `origin/master`, v1.5.0). Motivated by building a "Shape 1" Safe relay in `0xsequence/live-contracts` — a catapult job that broadcasts a fully-signed Gnosis Safe `execTransaction` on-chain, rather than emitting calldata for a human to paste into the Safe UI.* + +## The problem + +Building the relay we hit two real gaps: + +1. **No home for an opaque per-execution blob.** The packed Safe owner signatures are a large chunk of hex that changes every execution and is pure operational data. The only place to put a value today is `constants` YAML. That works but is the wrong shelf: constants are meant to be small, stable, shared configuration, not big per-run payloads, and they can't be `.gitignore`d cleanly. + +2. **`{{ref}}` only resolves when it is the *entire* value.** In `src/lib/core/resolver.ts` the reference match is anchored: + + ```ts + const refMatch = value.match(/^{{(.*)}}$/) + ``` + + So `".../multisig-transactions/{{tx-hash}}/"` is sent **literally** — which 404'd us against the Safe Transaction Service. `networks.yaml` gets embedded interpolation, but only via a special-case regex in the network loader (`resolveRpcUrlTokens`, `RPC*` tokens only), not the general resolver. + +We shipped the live-contracts job by working around both: a full-URL constant (couldn't template it) plus `json-request` + `read-json` to fetch the pre-packed `signatures` field from the tx-service at run time. + +## Options considered + +### A. Generic "blob registry" (a free-form `build-info`) — **rejected** + +The original ask was: should catapult have a place to store "blobs of data" that isn't constants — a free-form version of build-info? + +No. build-info earns its keep precisely because it is **typed and validated** (abi/bytecode, discoverable by hash) and referenced *semantically* via `Contract(name)`. A free-form analog has none of that — it is just "constants, but a second bag," with its own discovery/merge/duplicate-key machinery to build and maintain, and no added safety. + +This repo's own `notes/roadmap-thinking.md` (§3) already diagnosed the adjacent pain — build-info blobs hand-copied from other repos with the source link lost — and concluded the right frame is **provenance metadata about a canonical artifact**, not a generic blob bucket. A blob registry pulls in the opposite direction. The blob problem is better served by the smallest primitive that lets a blob live in *its own file*: `read-file`. + +### B. `read-file` value resolver — **built** + +A blob lives in its own file, referenced by path, resolved relative to the job dir, gitignorable, with an encoding hint. Small, general, and it composes with the resolvers that already exist (`read-json`, `slice-bytes`, `abi-encode`, …). + +### C. `concat` value resolver — **built** + +An explicit string-join, chosen over implicit whole-string interpolation. Implicit interpolation risks mangling values that legitimately contain `{{` and forces the resolver to guess intent; an explicit `concat` is unambiguous and self-documenting. Solves URL/path templating. + +### D. `safe-exec-transaction` std template — **sketched below, not built** + +Encapsulates the whole Shape-1 relay on top of the primitives + existing `json-request`/`read-json`. Worth doing, but it is a composition of primitives and should land after the primitives it depends on; it also overlaps with the `propose-transaction` / `pending`-state design in `roadmap-thinking.md` §2 and deserves that wider discussion. + +## What was built + +Two pure value resolvers, wired into the existing `ValueResolver` union and the resolver dispatch switch, matching the surrounding code's style and error conventions. + +### `read-file` + +```yaml +signatures: + type: "read-file" + arguments: + path: "signatures.hex" # relative to the job/template directory + encoding: "hex" # "utf8" (default) | "hex" | "json" +``` + +- `utf8` — returns text as-is with a single trailing newline trimmed (so file contents compare cleanly against inline strings). +- `hex` — validates and normalizes to a `0x`-prefixed lowercase hex string (accepts input with or without `0x`). +- `json` — parses the file and returns the value; composes with `read-json` to pull a nested field. + +### `concat` + +```yaml +url: + type: "concat" + arguments: + values: + - "https://safe-transaction-mainnet.safe.global/api/v1/multisig-transactions/" + - "{{safe-tx-hash}}" + - "/" + separator: "" # optional, default "" (direct concatenation) +``` + +Each part is resolved then coerced to a string (numbers/booleans allowed; objects/null/undefined rejected with a clear error) and joined by `separator`. + +### Files touched + +- `src/lib/types/values.ts` — `ReadFileValue`, `ConcatValue` interfaces + added to the `ValueResolver` union. +- `src/lib/core/resolver.ts` — imports, two dispatch cases, `resolveReadFile` / `resolveConcat` implementations. +- `src/lib/core/context.ts` — optional `projectRoot` constructor arg + `getProjectRoot()` (used to confine reads). Backwards-compatible: existing 5-arg callers and test mocks are unaffected. +- `src/lib/deployer.ts` — passes `options.projectRoot` into the context. +- `src/lib/core/__tests__/resolver.spec.ts` — `read-file` (11 cases) and `concat` (6 cases) describe blocks. +- `README.md` — `read-file` and `concat` sections under Value Resolvers. + +## Security considerations + +`read-file` reads from disk driven by YAML, so path handling is the whole risk surface: + +- **No absolute paths.** Rejected outright — a job must not name `/etc/...` or a home-dir key file. +- **Confined to the project root.** The path is resolved against the job/template directory, then checked with `path.relative(projectRoot, resolved)`; anything that starts with `..` or is absolute (i.e. escapes the root) is refused. `../../secrets` cannot climb out. (When `projectRoot` is unknown — e.g. a bare unit-test context — the resolver still rejects absolute paths and resolves relative to the context dir; the deployer always supplies `projectRoot` in real runs.) +- **No secret auto-discovery.** The resolver only reads the exact file named. It never scans, globs, or reads `.env`/keystores implicitly. Secrets (the deployer key) continue to arrive via env/CLI, never through this path. +- **Blobs are meant to be gitignorable.** Operational payloads (signatures) live in their own file the operator can `.gitignore`, keeping large per-run data out of git and out of `constants`. + +`concat` has no I/O and no injection surface beyond producing a string; object parts are rejected so a misresolved reference fails loudly instead of emitting `[object Object]`. + +## How live-contracts consumes it + +**Before** (what we actually shipped as a workaround): the endpoint URL had to be a single full-URL constant because it couldn't be templated, and the packed signatures came from a run-time `json-request` to the Safe Transaction Service because there was nowhere good to store them: + +```yaml +constants: + # Whole URL as one opaque constant — the tx hash could not be interpolated. + safe_tx_url: "https://safe-transaction-mainnet.safe.global/api/v1/multisig-transactions/0xabc.../" + +actions: + - name: fetch-sigs + type: "json-request" + arguments: + url: "{{safe_tx_url}}" + - name: relay + type: "send-transaction" + arguments: + to: "{{safe_address}}" + data: + type: "abi-encode" + arguments: + signature: "execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes)" + values: [ ..., { type: "read-json", arguments: { json: "{{fetch-sigs.response}}", path: "signatures" } } ] +``` + +**After** — the URL is templated with `concat`, and the collected signatures live in a gitignorable file read with `read-file` (offline, deterministic, no dependency on the tx-service being reachable at relay time): + +```yaml +constants: + safe_tx_service: "https://safe-transaction-mainnet.safe.global/api/v1" + +actions: + # Option 1: still fetch from the service, but build the URL with concat + - name: fetch-sigs + type: "json-request" + arguments: + url: + type: "concat" + values: + - "{{safe_tx_service}}" + - "/multisig-transactions/" + - "{{safe-tx-hash}}" + - "/" + + # Option 2: read pre-collected packed signatures straight from a file + - name: relay + type: "send-transaction" + arguments: + to: "{{safe_address}}" + data: + type: "abi-encode" + arguments: + signature: "execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes)" + values: + - # ...to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver... + - { type: "read-file", arguments: { path: "signatures.hex", encoding: "hex" } } +``` + +## Sketch: `safe-exec-transaction` std template (not implemented) + +A std template in `src/lib/std/templates/safe-exec-transaction.yaml`, taking the Safe address, the inner call, and either a signatures file or a tx-service base URL. It would: + +1. Compute/accept the SafeTxHash for the inner transaction. +2. Obtain packed signatures — either `read-file` (offline) or `json-request` + `read-json` against a `concat`-built tx-service URL (online). +3. `abi-encode` `execTransaction(...)` with the inner call + packed signatures. +4. `send-transaction` to the Safe. +5. Guard with a `skip_condition` that observes the on-chain effect (the same idempotent-convergence pattern as deployments), so a re-run either lands the tx or finds it already applied. + +Open design point: this overlaps with the `propose-transaction` + `pending`-state model in `roadmap-thinking.md` §2. The primitives here (`read-file`, `concat`) are useful regardless of which way that lands, which is the argument for shipping them independently first. + +## Test results + +`src/lib/core/__tests__/resolver.spec.ts`: 216/216 pass (includes the 11 new `read-file` + 6 new `concat` cases). Build is clean (`pnpm build`), lint has 0 errors (only the pre-existing repo-wide `no-explicit-any` warnings). + +The full suite has 4–5 pre-existing failures in `engine.spec.ts` (`send-signed-transaction`, `test-nicks-method`) — these fail identically on a clean `origin/master` tree and are environmental: the local node at `127.0.0.1:8545` is a Polygon mainnet fork (chainId `0x89`), not a clean instant-mining anvil with an unlocked funded account. They are unrelated to this change, which adds only two pure value resolvers and one optional constructor argument. diff --git a/src/lib/core/__tests__/resolver.spec.ts b/src/lib/core/__tests__/resolver.spec.ts index 168b14c..52417ca 100644 --- a/src/lib/core/__tests__/resolver.spec.ts +++ b/src/lib/core/__tests__/resolver.spec.ts @@ -1,8 +1,11 @@ import { ethers } from 'ethers' import { ValueResolver } from '../resolver' import { ExecutionContext } from '../context' -import { BasicArithmeticValue, Network, ReadBalanceValue, GetStorageAtValue, ComputeSlotValue, ComputeCreate2Value, ConstructorEncodeValue, AbiEncodeValue, AbiPackValue, CallValue, ContractExistsValue, ComputeCreateValue, SliceBytesValue } from '../../types' +import { BasicArithmeticValue, Network, ReadBalanceValue, GetStorageAtValue, ComputeSlotValue, ComputeCreate2Value, ConstructorEncodeValue, AbiEncodeValue, AbiPackValue, CallValue, ContractExistsValue, ComputeCreateValue, SliceBytesValue, ReadFileValue, ConcatValue } from '../../types' import { ContractRepository } from '../../contracts/repository' +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' describe('ValueResolver', () => { let resolver: ValueResolver @@ -2076,6 +2079,216 @@ describe('ValueResolver', () => { }) }) + describe('read-file', () => { + let projectRoot: string + let fileContext: ExecutionContext + + beforeEach(async () => { + // A self-contained project dir with a "jobs" subfolder acting as the + // context path (as if a job YAML lived there). + projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'catapult-read-file-')) + fs.mkdirSync(path.join(projectRoot, 'jobs')) + + const mockPrivateKey = '0x0000000000000000000000000000000000000000000000000000000000000001' + fileContext = new ExecutionContext( + mockNetwork, + mockPrivateKey, + mockRegistry, + undefined, + undefined, + projectRoot, + ) + // Pretend the resolving job/template lives at jobs/deploy.yaml + fileContext.setContextPath(path.join(projectRoot, 'jobs', 'deploy.yaml')) + }) + + afterEach(async () => { + if (fileContext) { + try { + await fileContext.dispose() + } catch (error) { + // Ignore cleanup errors + } + } + fs.rmSync(projectRoot, { recursive: true, force: true }) + }) + + it('should read a file relative to the job directory as utf8 and trim trailing newline', async () => { + fs.writeFileSync(path.join(projectRoot, 'jobs', 'note.txt'), 'hello world\n') + const value: ReadFileValue = { + type: 'read-file', + arguments: { path: 'note.txt' }, + } + const result = await resolver.resolve(value, fileContext) + expect(result).toBe('hello world') + }) + + it('should resolve paths relative to the job dir, not the project root', async () => { + // Same relative name in two places; the one next to the job must win. + fs.writeFileSync(path.join(projectRoot, 'data.txt'), 'root') + fs.writeFileSync(path.join(projectRoot, 'jobs', 'data.txt'), 'job') + const value: ReadFileValue = { + type: 'read-file', + arguments: { path: 'data.txt' }, + } + const result = await resolver.resolve(value, fileContext) + expect(result).toBe('job') + }) + + it('should read a file as normalized 0x-prefixed hex', async () => { + fs.writeFileSync(path.join(projectRoot, 'jobs', 'sig.hex'), '0xDEADBEEF\n') + const value: ReadFileValue = { + type: 'read-file', + arguments: { path: 'sig.hex', encoding: 'hex' }, + } + const result = await resolver.resolve(value, fileContext) + expect(result).toBe('0xdeadbeef') + }) + + it('should accept hex without a 0x prefix', async () => { + fs.writeFileSync(path.join(projectRoot, 'jobs', 'sig.hex'), 'aabbcc') + const value: ReadFileValue = { + type: 'read-file', + arguments: { path: 'sig.hex', encoding: 'hex' }, + } + const result = await resolver.resolve(value, fileContext) + expect(result).toBe('0xaabbcc') + }) + + it('should reject odd-length or non-hex contents when encoding is hex', async () => { + fs.writeFileSync(path.join(projectRoot, 'jobs', 'bad.hex'), '0xabc') + const value: ReadFileValue = { + type: 'read-file', + arguments: { path: 'bad.hex', encoding: 'hex' }, + } + await expect(resolver.resolve(value, fileContext)).rejects.toThrow(/not valid hex/) + }) + + it('should parse a file as JSON', async () => { + fs.writeFileSync(path.join(projectRoot, 'jobs', 'data.json'), JSON.stringify({ a: 1, b: [2, 3] })) + const value: ReadFileValue = { + type: 'read-file', + arguments: { path: 'data.json', encoding: 'json' }, + } + const result = await resolver.resolve<{ a: number; b: number[] }>(value, fileContext) + expect(result).toEqual({ a: 1, b: [2, 3] }) + }) + + it('should reference a nested value from a JSON file via read-json', async () => { + fs.writeFileSync(path.join(projectRoot, 'jobs', 'safe.json'), JSON.stringify({ signatures: '0x1234' })) + const value = { + type: 'read-json', + arguments: { + json: { type: 'read-file', arguments: { path: 'safe.json', encoding: 'json' } }, + path: 'signatures', + }, + } + const result = await resolver.resolve(value as any, fileContext) + expect(result).toBe('0x1234') + }) + + it('should reject absolute paths', async () => { + const value: ReadFileValue = { + type: 'read-file', + arguments: { path: path.join(projectRoot, 'jobs', 'note.txt') }, + } + await expect(resolver.resolve(value, fileContext)).rejects.toThrow(/absolute paths are not allowed/) + }) + + it('should refuse to read outside the project root via ..', async () => { + // Create a file one level above the project root and try to reach it. + const outside = path.join(projectRoot, '..', 'outside-secret.txt') + fs.writeFileSync(outside, 'top secret') + try { + const value: ReadFileValue = { + type: 'read-file', + arguments: { path: '../../outside-secret.txt' }, + } + await expect(resolver.resolve(value, fileContext)).rejects.toThrow(/outside the project root/) + } finally { + fs.rmSync(outside, { force: true }) + } + }) + + it('should surface a clear error for a missing file', async () => { + const value: ReadFileValue = { + type: 'read-file', + arguments: { path: 'does-not-exist.txt' }, + } + await expect(resolver.resolve(value, fileContext)).rejects.toThrow(/failed to read/) + }) + + it('should reject an unknown encoding', async () => { + fs.writeFileSync(path.join(projectRoot, 'jobs', 'note.txt'), 'x') + const value = { + type: 'read-file', + arguments: { path: 'note.txt', encoding: 'base64' }, + } + await expect(resolver.resolve(value as any, fileContext)).rejects.toThrow(/unknown encoding/) + }) + }) + + describe('concat', () => { + it('should join string parts with no separator by default', async () => { + const value: ConcatValue = { + type: 'concat', + arguments: { values: ['a', 'b', 'c'] }, + } + const result = await resolver.resolve(value, context) + expect(result).toBe('abc') + }) + + it('should join with a separator', async () => { + const value: ConcatValue = { + type: 'concat', + arguments: { values: ['a', 'b', 'c'], separator: '/' }, + } + const result = await resolver.resolve(value, context) + expect(result).toBe('a/b/c') + }) + + it('should coerce numbers and booleans to strings', async () => { + const value: ConcatValue = { + type: 'concat', + arguments: { values: ['id-', 42, '-', true] }, + } + const result = await resolver.resolve(value, context) + expect(result).toBe('id-42-true') + }) + + it('should resolve nested references and build a templated URL', async () => { + context.setOutput('tx.hash', '0xabc123') + const value: ConcatValue = { + type: 'concat', + arguments: { + values: [ + 'https://safe.example.com/multisig-transactions/', + '{{tx.hash}}', + '/confirmations', + ], + }, + } + const result = await resolver.resolve(value, context) + expect(result).toBe('https://safe.example.com/multisig-transactions/0xabc123/confirmations') + }) + + it('should reject object parts', async () => { + const value: ConcatValue = { + type: 'concat', + arguments: { values: ['a', { foo: 'bar' } as any] }, + } + await expect(resolver.resolve(value, context)).rejects.toThrow(/cannot concatenate an object/) + }) + + it('should reject null or undefined parts', async () => { + const value: ConcatValue = { + type: 'concat', + arguments: { values: ['a', null as any] }, + } + await expect(resolver.resolve(value, context)).rejects.toThrow(/null or undefined/) + }) + }) + describe('get-storage-at', () => { const testAddress = '0x1234567890123456789012345678901234567890' const testSlot = '0x0000000000000000000000000000000000000000000000000000000000000000' diff --git a/src/lib/core/context.ts b/src/lib/core/context.ts index 9c22d57..f210c4a 100644 --- a/src/lib/core/context.ts +++ b/src/lib/core/context.ts @@ -12,6 +12,7 @@ export class ExecutionContext { private etherscanApiKey?: string private currentContextPath?: string private resolvedSigner?: DigestSigner // Cache for resolved signer + private projectRoot?: string // Absolute project root, used to confine file reads // Constants registries private topLevelConstants: Map = new Map() @@ -22,12 +23,14 @@ export class ExecutionContext { privateKey: string | undefined, // Make privateKey optional contractRepository: ContractRepository, etherscanApiKey?: string, - topLevelConstants?: Map + topLevelConstants?: Map, + projectRoot?: string ) { this.network = network this.provider = new ethers.JsonRpcProvider(network.rpcUrl) this.contractRepository = contractRepository this.etherscanApiKey = etherscanApiKey + this.projectRoot = projectRoot if (topLevelConstants) { this.topLevelConstants = new Map(topLevelConstants) } @@ -101,6 +104,10 @@ export class ExecutionContext { return this.currentContextPath } + public getProjectRoot(): string | undefined { + return this.projectRoot + } + // Constants management public setJobConstants(constants?: Record): void { this.jobConstants = new Map(Object.entries(constants || {})) diff --git a/src/lib/core/resolver.ts b/src/lib/core/resolver.ts index 2281d73..c861b91 100644 --- a/src/lib/core/resolver.ts +++ b/src/lib/core/resolver.ts @@ -17,9 +17,13 @@ import { ReadJsonValue, ValueEmptyValue, SliceBytesValue, + ReadFileValue, + ConcatValue, } from '../types' import { ExecutionContext } from './context' import { isAddress, isBigNumberish, isBytesLike } from '../utils/assertion' +import * as fs from 'fs' +import * as path from 'path' /** * A scope for resolving local variables, such as template arguments. @@ -219,6 +223,10 @@ export class ValueResolver { return this.resolveValueEmpty(resolvedArgs as ValueEmptyValue['arguments']) case 'slice-bytes': return this.resolveSliceBytes(resolvedArgs as SliceBytesValue['arguments']) + case 'read-file': + return this.resolveReadFile(resolvedArgs as ReadFileValue['arguments'], context) + case 'concat': + return this.resolveConcat(resolvedArgs as ConcatValue['arguments']) default: throw new Error(`Unknown value resolver type: ${(obj as any).type}`) } @@ -851,6 +859,104 @@ export class ValueResolver { return normalized } + /** + * Reads a file relative to the current job/template directory and returns its + * contents according to the requested encoding. The resolved path is confined + * to the project root so a job can never read arbitrary files on the host. + * @private + */ + private resolveReadFile(args: ReadFileValue['arguments'], context: ExecutionContext): any { + const { path: filePath, encoding } = args + + if (typeof filePath !== 'string' || filePath.length === 0) { + throw new Error('read-file: "path" is required and must be a non-empty string') + } + + if (path.isAbsolute(filePath)) { + throw new Error(`read-file: absolute paths are not allowed ("${filePath}"); use a path relative to the job directory`) + } + + const enc = (encoding as string) ?? 'utf8' + if (enc !== 'utf8' && enc !== 'hex' && enc !== 'json') { + throw new Error(`read-file: unknown encoding "${enc}" (expected "utf8", "hex", or "json")`) + } + + // Base directory: the directory of the job/template that referenced the file. + // getContextPath() is the YAML file path; fall back to the project root, then cwd. + const contextPath = context.getContextPath() + const baseDir = contextPath ? path.dirname(contextPath) : (context.getProjectRoot() ?? process.cwd()) + const resolvedPath = path.resolve(baseDir, filePath) + + // Confine reads to the project root when it is known. This prevents `../` + // traversal from escaping the project (e.g. reading host secrets). + const projectRoot = context.getProjectRoot() + if (projectRoot) { + const normalizedRoot = path.resolve(projectRoot) + const rel = path.relative(normalizedRoot, resolvedPath) + if (rel.startsWith('..') || path.isAbsolute(rel)) { + throw new Error(`read-file: refusing to read "${filePath}" — it resolves outside the project root`) + } + } + + let contents: string + try { + contents = fs.readFileSync(resolvedPath, 'utf8') + } catch (error) { + throw new Error(`read-file: failed to read "${filePath}" (resolved to ${resolvedPath}): ${error instanceof Error ? error.message : String(error)}`) + } + + switch (enc) { + case 'utf8': + // Trim a single trailing newline so a file's contents compare cleanly + // against inline strings (editors and `echo` append one by convention). + return contents.replace(/\r?\n$/, '') + case 'hex': { + const trimmed = contents.trim() + const body = trimmed.startsWith('0x') || trimmed.startsWith('0X') ? trimmed.slice(2) : trimmed + if (body.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(body)) { + throw new Error(`read-file: "${filePath}" is not valid hex`) + } + return `0x${body.toLowerCase()}` + } + case 'json': + try { + return JSON.parse(contents) + } catch (error) { + throw new Error(`read-file: "${filePath}" is not valid JSON: ${error instanceof Error ? error.message : String(error)}`) + } + } + } + + /** + * Joins the resolved parts into a single string, separated by an optional + * separator. This is the explicit form of string templating; it avoids the + * ambiguity of interpolating `{{...}}` inside arbitrary literals. + * @private + */ + private resolveConcat(args: ConcatValue['arguments']): string { + const { values, separator } = args + + if (!Array.isArray(values)) { + throw new Error('concat: "values" must be an array') + } + + if (separator !== undefined && typeof separator !== 'string') { + throw new Error('concat: "separator" must be a string') + } + + const parts = values.map(v => { + if (v === null || v === undefined) { + throw new Error('concat: cannot concatenate a null or undefined value') + } + if (typeof v === 'object') { + throw new Error(`concat: cannot concatenate an object value (${JSON.stringify(v)}); resolve it to a string first`) + } + return String(v) + }) + + return parts.join(separator ?? '') + } + /** * Helper to recursively resolve the `arguments` field of any `ValueResolver` object. * @private diff --git a/src/lib/deployer.ts b/src/lib/deployer.ts index 0d9cab5..e35f5af 100644 --- a/src/lib/deployer.ts +++ b/src/lib/deployer.ts @@ -213,7 +213,8 @@ export class Deployer { this.options.privateKey, this.loader.contractRepository, this.options.etherscanApiKey, - this.loader.constants + this.loader.constants, + this.options.projectRoot ) // Set job-level constants if present (guard for mocked contexts in tests) if (typeof (context as unknown as { setJobConstants?: (constants: unknown) => void }).setJobConstants === 'function') { diff --git a/src/lib/types/values.ts b/src/lib/types/values.ts index b5f0506..1b5b127 100644 --- a/src/lib/types/values.ts +++ b/src/lib/types/values.ts @@ -186,6 +186,48 @@ export interface SliceBytesValue { }; } +/** + * Reads the raw contents of a file from disk as a value. + * + * The path is resolved relative to the directory of the job/template that uses + * it (the current context path), never the process working directory, and is + * confined to the project root: absolute paths and any `..` segment that would + * escape the project are rejected. This is the intended home for large, opaque, + * per-execution operational blobs (e.g. packed multisig signatures) that do not + * belong in `constants` and are not typed build artifacts. + */ +export interface ReadFileValue { + type: 'read-file'; + arguments: { + /** Path to the file, relative to the current job/template directory. */ + path: Value; + /** + * How to interpret the file contents (default: "utf8"): + * - "utf8": return the text as-is (trailing newline trimmed). + * - "hex": return the text as a validated, 0x-prefixed lowercase hex string. + * - "json": parse the text as JSON and return the resulting value. + */ + encoding?: Value<'utf8' | 'hex' | 'json'>; + }; +} + +/** + * Concatenates its parts into a single string after resolving each one. + * + * This is the explicit alternative to whole-string interpolation: it lets a + * value embed references inside a longer string (URLs, paths) without the + * resolver having to guess whether a stray `{{` in a literal is a reference. + * Each part is resolved and coerced to a string; the results are joined by an + * optional `separator` (default: "", i.e. direct concatenation). + */ +export interface ConcatValue { + type: 'concat'; + arguments: { + values: Value[]; + separator?: Value; + }; +} + /** * A union of all possible value-resolver objects. */ @@ -205,7 +247,9 @@ export type ValueResolver = | ReadJsonValue | ResolveJsonValue | ValueEmptyValue - | SliceBytesValue; + | SliceBytesValue + | ReadFileValue + | ConcatValue; /** * A generic value type that can be a primitive literal (string, number, boolean), From 38da955eb1738f214e38d20953a869cbb3330e9e Mon Sep 17 00:00:00 2001 From: agusx1211 Date: Wed, 1 Jul 2026 17:13:34 +0000 Subject: [PATCH 2/2] feat(std): add safe-exec-transaction template (Shape 1 Safe relay) A std template that assembles and broadcasts a fully-signed Gnosis Safe execTransaction on-chain, rather than emitting calldata for a human to paste into the Safe UI. Built on abi-encode + send-transaction and the new read-file/concat resolvers. Takes a single resolved `signatures` argument; the caller chooses the source (read-file for offline/air-gapped, or json-request + read-json against a concat-built Safe Transaction Service URL for the hosted flow). Catapult has no conditional/coalesce resolver, so the file-vs-service choice deliberately lives with the caller rather than inside the template, keeping it single-responsibility (assemble + broadcast). No post-execution skip condition: the desired state is the inner call's effect, which the caller gates with job-level skip_if. Test parses the shipped YAML, runs it through the engine, and asserts the broadcast tx carries execTransaction calldata matching an independent ethers encoding. README documents both signature-source patterns. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 50 +++++++ notes/read-file-and-concat-value-resolvers.md | 43 ++++-- .../__tests__/safe-exec-transaction.spec.ts | 140 ++++++++++++++++++ .../std/templates/safe-exec-transaction.yaml | 112 ++++++++++++++ 4 files changed, 333 insertions(+), 12 deletions(-) create mode 100644 src/lib/core/__tests__/safe-exec-transaction.spec.ts create mode 100644 src/lib/std/templates/safe-exec-transaction.yaml diff --git a/README.md b/README.md index 614bb63..8ffdfff 100644 --- a/README.md +++ b/README.md @@ -983,8 +983,58 @@ Catapult includes several standard templates: - **`erc-2470`** and raw variant: CREATE2 Deployer (singleton factory) - **`assured-deployment`**: Helper to ensure a contract is deployed at a specific address - **`min-balance`**: Ensure minimum balance for any given address +- **`safe-exec-transaction`**: Assemble and broadcast a fully-signed Gnosis Safe `execTransaction` (see below) - Raw building blocks: `raw-sequence-universal-deployer-2`, `raw-nano-universal-deployer`, `raw-erc-2470` +### `safe-exec-transaction` + +Broadcasts a fully-signed Gnosis Safe transaction on-chain ("Shape 1" relay) instead of stopping at calldata for a human to paste into the Safe UI. It packs the owner signatures the Safe already collected into `execTransaction` and sends the outer transaction with the configured EOA as relayer. + +The packed signatures are passed as the `signatures` argument. Where they come from is the caller's choice — the two common sources: + +Offline / air-gapped (signatures collected into a gitignorable file): + +```yaml +- name: "relay" + template: "safe-exec-transaction" + arguments: + safe: "{{safe_address}}" + to: "{{target}}" + data: "{{inner_calldata}}" + operation: "0" # 0 = CALL, 1 = DELEGATECALL + signatures: + type: "read-file" + arguments: { path: "signatures.hex", encoding: "hex" } +``` + +Safe Transaction Service (hosted flow — the service returns a top-level pre-packed `signatures` field): + +```yaml +- name: "fetch-safe-tx" + type: "json-request" + arguments: + url: + type: "concat" + arguments: + values: + - "https://safe-transaction-mainnet.safe.global/api" + - "/v1/multisig-transactions/" + - "{{safe_tx_hash}}" + - "/" +- name: "relay" + template: "safe-exec-transaction" + arguments: + safe: "{{safe_address}}" + to: "{{target}}" + data: "{{inner_calldata}}" + operation: "0" + signatures: + type: "read-json" + arguments: { json: "{{fetch-safe-tx.response}}", path: "signatures" } +``` + +The template only assembles and broadcasts; it does not impose a post-execution skip condition, because the desired state of a Safe relay is the effect of the *inner* call. Wrap the calling job with `skip_if` observing that on-chain effect (e.g. `owner() == newOwner`) for idempotent, re-runnable convergence. + ## Contract Resolution Catapult automatically discovers and indexes contract artifacts in your project. It supports: diff --git a/notes/read-file-and-concat-value-resolvers.md b/notes/read-file-and-concat-value-resolvers.md index d57a288..b66906b 100644 --- a/notes/read-file-and-concat-value-resolvers.md +++ b/notes/read-file-and-concat-value-resolvers.md @@ -36,9 +36,9 @@ A blob lives in its own file, referenced by path, resolved relative to the job d An explicit string-join, chosen over implicit whole-string interpolation. Implicit interpolation risks mangling values that legitimately contain `{{` and forces the resolver to guess intent; an explicit `concat` is unambiguous and self-documenting. Solves URL/path templating. -### D. `safe-exec-transaction` std template — **sketched below, not built** +### D. `safe-exec-transaction` std template — **built** -Encapsulates the whole Shape-1 relay on top of the primitives + existing `json-request`/`read-json`. Worth doing, but it is a composition of primitives and should land after the primitives it depends on; it also overlaps with the `propose-transaction` / `pending`-state design in `roadmap-thinking.md` §2 and deserves that wider discussion. +Encapsulates the Shape-1 relay on top of the primitives + existing `json-request`/`read-json`/`abi-encode`/`send-transaction`. See "The template" section below for the final shape and the one design decision that fell out of it (a single `signatures` argument rather than baking the file-vs-service choice into the template). ## What was built @@ -79,8 +79,10 @@ Each part is resolved then coerced to a string (numbers/booleans allowed; object - `src/lib/core/resolver.ts` — imports, two dispatch cases, `resolveReadFile` / `resolveConcat` implementations. - `src/lib/core/context.ts` — optional `projectRoot` constructor arg + `getProjectRoot()` (used to confine reads). Backwards-compatible: existing 5-arg callers and test mocks are unaffected. - `src/lib/deployer.ts` — passes `options.projectRoot` into the context. +- `src/lib/std/templates/safe-exec-transaction.yaml` — the Shape-1 relay std template. - `src/lib/core/__tests__/resolver.spec.ts` — `read-file` (11 cases) and `concat` (6 cases) describe blocks. -- `README.md` — `read-file` and `concat` sections under Value Resolvers. +- `src/lib/core/__tests__/safe-exec-transaction.spec.ts` — parses the shipped template and runs it end-to-end (2 cases). +- `README.md` — `read-file` and `concat` sections under Value Resolvers, plus a `safe-exec-transaction` section under Standard Templates. ## Security considerations @@ -151,20 +153,37 @@ actions: - { type: "read-file", arguments: { path: "signatures.hex", encoding: "hex" } } ``` -## Sketch: `safe-exec-transaction` std template (not implemented) +## The template: `safe-exec-transaction` -A std template in `src/lib/std/templates/safe-exec-transaction.yaml`, taking the Safe address, the inner call, and either a signatures file or a tx-service base URL. It would: +Shipped at `src/lib/std/templates/safe-exec-transaction.yaml`. It takes the Safe address, the ten `execTransaction` parameters (`to`, `value`, `data`, `operation`, `safeTxGas`, `baseGas`, `gasPrice`, `gasToken`, `refundReceiver`) and the packed owner `signatures`, then: -1. Compute/accept the SafeTxHash for the inner transaction. -2. Obtain packed signatures — either `read-file` (offline) or `json-request` + `read-json` against a `concat`-built tx-service URL (online). -3. `abi-encode` `execTransaction(...)` with the inner call + packed signatures. -4. `send-transaction` to the Safe. -5. Guard with a `skip_condition` that observes the on-chain effect (the same idempotent-convergence pattern as deployments), so a re-run either lands the tx or finds it already applied. +1. `abi-encode`s `execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes)` with those values. +2. `send-transaction`s that calldata to the Safe (the configured EOA is the relayer). +3. Exposes the outer tx hash as the template output `hash`. -Open design point: this overlaps with the `propose-transaction` + `pending`-state model in `roadmap-thinking.md` §2. The primitives here (`read-file`, `concat`) are useful regardless of which way that lands, which is the argument for shipping them independently first. +```yaml +- name: "relay" + template: "safe-exec-transaction" + arguments: + safe: "{{safe_address}}" + to: "{{target}}" + data: "{{inner_calldata}}" + operation: "0" + signatures: + type: "read-file" # offline: a gitignorable signatures file + arguments: { path: "signatures.hex", encoding: "hex" } +``` + +For the hosted flow, the caller fetches the signatures with a sibling `json-request` (URL built with `concat`) and passes `read-json` of the response's `signatures` field — see README for that variant. + +**Design decision — one `signatures` argument, not a file-vs-service switch inside the template.** My first cut tried to make the template pick the source (fetch from the tx-service *or* read the file). That can't be expressed cleanly today: catapult has no conditional/coalesce value resolver, so a `read-file` with an empty path just errors — there's no "use A else B". Forcing it would have meant either a new `conditional` resolver (out of scope) or an ugly always-fetch-then-maybe-ignore. Instead the template takes a single resolved `signatures` value and the *caller* chooses the source with the resolver they want (`read-file` or `json-request`+`read-json`). This keeps the template single-responsibility (assemble + broadcast), mirrors how `min-balance` takes an already-resolved `balance`, and makes both source patterns first-class in the docs rather than one being privileged. + +**No post-execution skip condition in the template.** The desired state of a Safe relay is the effect of the *inner* call, which only the caller knows, so the template must not impose a generic post-check (catapult's template `skip_condition` is post-checked and would fail spuriously). The caller wraps the job with `skip_if` (a pure pre-gate that already exists for exactly the multisig-payload case — see `Job.skip_if` and `roadmap-thinking.md` §2) observing the on-chain effect, giving the same idempotent, re-runnable convergence as deployments. + +Open design point: this overlaps with the `propose-transaction` + `pending`-state model in `roadmap-thinking.md` §2 — a fuller lifecycle where catapult also *collects* signatures. `safe-exec-transaction` covers the "signatures already gathered, broadcast them" half; the primitives (`read-file`, `concat`) are useful regardless of how the collection half lands. ## Test results -`src/lib/core/__tests__/resolver.spec.ts`: 216/216 pass (includes the 11 new `read-file` + 6 new `concat` cases). Build is clean (`pnpm build`), lint has 0 errors (only the pre-existing repo-wide `no-explicit-any` warnings). +`resolver.spec.ts`: 216/216 pass (includes the 11 new `read-file` + 6 new `concat` cases). `safe-exec-transaction.spec.ts`: 2/2 pass — it parses the actually-shipped YAML, runs the template through the engine, and asserts the broadcast transaction goes to the Safe with `execTransaction` calldata that matches an independently-computed ethers encoding (an EOA stands in for the Safe so no real Safe is needed). Build is clean (`pnpm build`), lint has 0 errors (only the pre-existing repo-wide `no-explicit-any` warnings). The full suite has 4–5 pre-existing failures in `engine.spec.ts` (`send-signed-transaction`, `test-nicks-method`) — these fail identically on a clean `origin/master` tree and are environmental: the local node at `127.0.0.1:8545` is a Polygon mainnet fork (chainId `0x89`), not a clean instant-mining anvil with an unlocked funded account. They are unrelated to this change, which adds only two pure value resolvers and one optional constructor argument. diff --git a/src/lib/core/__tests__/safe-exec-transaction.spec.ts b/src/lib/core/__tests__/safe-exec-transaction.spec.ts new file mode 100644 index 0000000..1e0efdb --- /dev/null +++ b/src/lib/core/__tests__/safe-exec-transaction.spec.ts @@ -0,0 +1,140 @@ +import { ethers } from 'ethers' +import * as fs from 'fs' +import * as path from 'path' +import { ExecutionEngine } from '../engine' +import { ExecutionContext } from '../context' +import { parseTemplate } from '../../parsers' +import { ContractRepository } from '../../contracts/repository' +import { VerificationPlatformRegistry } from '../../verification/etherscan' +import { Job, Network, Template } from '../../types' + +// First anvil account (also funded on the local Polygon-fork node used in CI). +const TEST_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' +// A plain EOA stands in for the Safe: it accepts any calldata, so we can prove +// the template assembles + broadcasts execTransaction without a real Safe. +const SAFE_ADDRESS = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' +const INNER_TARGET = '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC' + +const EXEC_TRANSACTION_SIG = + 'execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes)' + +describe('safe-exec-transaction std template', () => { + let engine: ExecutionEngine + let context: ExecutionContext + let mockNetwork: Network + let mockRegistry: ContractRepository + let templates: Map + let anvilProvider: ethers.JsonRpcProvider + let template: Template + + beforeAll(async () => { + const rpcUrl = process.env.RPC_URL || 'http://127.0.0.1:8545' + mockNetwork = { name: 'testnet', chainId: 999, rpcUrl } + const provider = new ethers.JsonRpcProvider(rpcUrl) + await provider.getNetwork() + + // Parse the ACTUAL shipped template so the test exercises what we ship. + const templatePath = path.resolve(__dirname, '..', '..', 'std', 'templates', 'safe-exec-transaction.yaml') + template = parseTemplate(fs.readFileSync(templatePath, 'utf8')) + }) + + beforeEach(async () => { + const rpcUrl = process.env.RPC_URL || 'http://127.0.0.1:8545' + anvilProvider = new ethers.JsonRpcProvider(rpcUrl) + + mockRegistry = new ContractRepository() + context = new ExecutionContext(mockNetwork, TEST_PRIVATE_KEY, mockRegistry) + + templates = new Map() + templates.set('safe-exec-transaction', template) + + const verificationRegistry = new VerificationPlatformRegistry() + engine = new ExecutionEngine(templates, { verificationRegistry }) + }) + + afterEach(async () => { + if (anvilProvider) { + try { + if (anvilProvider.destroy) await anvilProvider.destroy() + } catch (error) { + // Ignore cleanup errors + } + } + if (context) { + try { + await context.dispose() + } catch (error) { + // Ignore cleanup errors + } + } + }) + + it('parses as a valid template with the expected argument surface', () => { + expect(template.name).toBe('safe-exec-transaction') + expect(Object.keys(template.arguments || {})).toEqual( + expect.arrayContaining(['safe', 'to', 'data', 'operation', 'signatures']), + ) + expect(template.returns?.hash).toBeDefined() + }) + + it('assembles execTransaction from the inner call + packed signatures and broadcasts it', async () => { + const innerData = '0xabcdef' + // A synthetic packed signature blob (1 owner: r || s || v). Contents are + // opaque to the template; we only assert they are forwarded verbatim. + const signatures = + '0x' + + '11'.repeat(32) + // r + '22'.repeat(32) + // s + '1b' // v + + const job: Job = { + name: 'relay-safe-tx', + version: '1.0.0', + actions: [ + { + name: 'exec', + template: 'safe-exec-transaction', + arguments: { + safe: SAFE_ADDRESS, + to: INNER_TARGET, + data: innerData, + value: '0', + operation: '0', + safeTxGas: '0', + baseGas: '0', + gasPrice: '0', + gasToken: ethers.ZeroAddress, + refundReceiver: ethers.ZeroAddress, + signatures, + }, + }, + ], + } + + await expect(engine.executeJob(job, context)).resolves.not.toThrow() + + const hash = context.getOutput('exec.hash') + expect(hash).toBeDefined() + + // The broadcast transaction must go to the Safe and carry the exact + // execTransaction calldata we expect. + const iface = new ethers.Interface([`function ${EXEC_TRANSACTION_SIG}`]) + const expectedData = iface.encodeFunctionData('execTransaction', [ + INNER_TARGET, + 0n, + innerData, + 0, + 0n, + 0n, + 0n, + ethers.ZeroAddress, + ethers.ZeroAddress, + signatures, + ]) + + const tx = await anvilProvider.getTransaction(hash) + expect(tx).not.toBeNull() + expect(ethers.getAddress(tx!.to as string)).toBe(ethers.getAddress(SAFE_ADDRESS)) + expect(tx!.data.toLowerCase()).toBe(expectedData.toLowerCase()) + }) +}) diff --git a/src/lib/std/templates/safe-exec-transaction.yaml b/src/lib/std/templates/safe-exec-transaction.yaml new file mode 100644 index 0000000..86d96e4 --- /dev/null +++ b/src/lib/std/templates/safe-exec-transaction.yaml @@ -0,0 +1,112 @@ +# Broadcasts a fully-signed Gnosis Safe transaction on-chain ("Shape 1" relay). +# +# Instead of stopping at "here is the calldata, paste it into the Safe UI", this +# template assembles and sends the Safe's execTransaction itself: it packs the +# owner signatures the Safe already gathered into execTransaction and broadcasts +# the outer transaction with the configured EOA as the relayer. +# +# The packed owner signatures are passed in as the `signatures` argument. Where +# they come from is the caller's choice; the two common sources are: +# +# - A pre-collected file (offline / air-gapped signing). The blob lives in its +# own gitignorable file, read with the `read-file` resolver: +# +# signatures: +# type: "read-file" +# arguments: { path: "signatures.hex", encoding: "hex" } +# +# - The Safe Transaction Service (the common hosted flow). The service returns a +# top-level pre-packed `signatures` field for a given SafeTxHash. Fetch it with +# `json-request` + `read-json`, building the URL with `concat` (a `{{ref}}` is +# only interpolated when it is the ENTIRE value, not embedded in a string): +# +# # elsewhere in the job: +# - name: "fetch-safe-tx" +# type: "json-request" +# arguments: +# url: +# type: "concat" +# arguments: +# values: +# - "{{txServiceUrl}}" # e.g. https://safe-transaction-mainnet.safe.global/api +# - "/v1/multisig-transactions/" +# - "{{safeTxHash}}" +# - "/" +# # then pass: +# signatures: +# type: "read-json" +# arguments: { json: "{{fetch-safe-tx.response}}", path: "signatures" } +# +# This template only assembles and broadcasts. It intentionally does NOT impose a +# post-execution skip condition, because the "desired state" of a Safe relay is +# the effect of the INNER call, which only the caller knows. Wrap the calling job +# with `skip_if` observing that on-chain effect (e.g. `owner() == newOwner`) to get +# idempotent, re-runnable convergence. +name: "safe-exec-transaction" +type: "template" +description: "Assemble and broadcast a fully-signed Gnosis Safe execTransaction (Shape 1 relay)" + +arguments: + safe: + type: "address" + description: "Address of the Gnosis Safe that will execute the transaction" + to: + type: "address" + description: "Target of the inner Safe transaction" + data: + type: "bytes" + description: "Calldata of the inner Safe transaction" + value: + type: "uint256" + description: "Native value of the inner Safe transaction (default 0)" + operation: + type: "uint8" + description: "Safe operation: 0 = CALL, 1 = DELEGATECALL (default 0)" + safeTxGas: + type: "uint256" + description: "safeTxGas parameter of the Safe transaction (default 0)" + baseGas: + type: "uint256" + description: "baseGas parameter of the Safe transaction (default 0)" + gasPrice: + type: "uint256" + description: "gasPrice parameter of the Safe transaction (default 0)" + gasToken: + type: "address" + description: "gasToken parameter (default 0x0, i.e. native token)" + refundReceiver: + type: "address" + description: "refundReceiver parameter (default 0x0)" + signatures: + type: "bytes" + description: "Packed owner signatures for the Safe transaction (see header for how to source them)" + +actions: + - name: "relay" + type: "send-transaction" + arguments: + to: "{{safe}}" + value: "0" + data: + type: "abi-encode" + arguments: + signature: "execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes)" + values: + - "{{to}}" + - "{{value}}" + - "{{data}}" + - "{{operation}}" + - "{{safeTxGas}}" + - "{{baseGas}}" + - "{{gasPrice}}" + - "{{gasToken}}" + - "{{refundReceiver}}" + - "{{signatures}}" + +returns: + hash: + type: "bytes32" + description: "Transaction hash of the broadcast execTransaction" + +outputs: + hash: "{{relay.hash}}"