Skip to content
Open
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ serde_yaml = "0.9"

# alloy
alloy-primitives = { version = "1", features = ["rand", "serde"] }
alloy-sol-types = "1"
alloy-dyn-abi = { version = "1", features = ["eip712"] }
alloy-json-abi = "1"
alloy-signer = "2"
Expand Down
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1632,6 +1632,13 @@ Supported binding references:
| `u64` | `<name>` |
| `string` | `<name>` |

Add `save: <name>` to a sequence step to expose its signed transaction to later
steps as `<name>.raw` (EIP-2718 bytes), `<name>.tx_hash`, and `<name>.sender`.
Saved names must be nonempty, contain no dots, and not collide with another
binding or saved step in that instance. These outputs are derived offline, not
from receipts or return values. Only saved steps are signed synchronously;
other steps retain parallel signing and ordered output.

Sequences also expose `{ var: chain_id }` as the top-level workload `chain_id` unless a binding named `chain_id` is defined.

Hash bindings can reference other sequence bindings and are resolved once per sequence instance. For deterministic IDs that contracts compute with `keccak256(abi.encode(...))` (such as Tempo MPP channel IDs), use `abi_hash`:
Expand Down Expand Up @@ -1665,6 +1672,52 @@ When set, `txgen generate -n` counts emitted transactions, not sequence instance

See `examples/sequence.yaml` for a small syntax example, `examples/tip20-sequence.yaml` for a Tempo TIP20 `approve -> transferFrom` sequence whose second transaction depends on the first, and `examples/tip20-mpp.yaml` for TIP20 transfers mixed with deterministic MPP channel `open -> close` sequences.

#### Native MPP settlement

For the native TIP-20 Channel Reserve at
`0x4d50500000000000000000000000000000000000`, save the signed opening transaction
and use a Tempo `mpp_settle` template for the next step:

```yaml
templates:
# mpp_open calls the native reserve's open(payee, operator, token, deposit,
# salt, authorizedSigner), with payer.ref as from and payee.address as payee.
mpp_settle:
type: tempo
from: { var: payee.ref }
gas_limit: 1000000
expiring_nonce: true
valid_for_secs: 25
mpp_settle:
open_transaction: { var: opened.raw }
voucher_signer: { var: payer.ref }
cumulative_amount: 1

sequences:
mpp_open_settle:
bindings:
payer: { account: { pool: users, select: random } }
payee: { account: { pool: users, select: random } }
steps:
- template: mpp_open
save: opened
- template: mpp_settle
```

The adapter decodes the signed open, recovers the payer, derives its transaction
context hash and channel descriptor, and signs the reserve's EIP-712 voucher.
This includes the opening transaction's actual nonce, validity window, and
signing context; a salt alone is not sufficient to derive the native channel ID.
No RPC receipt lookup is required, and sequence scheduling still enforces order.

`call_index` selects the open in a batched transaction (default `0`). The
settlement sender must be the payee or operator; `voucher_signer` must select the
authorized signer, or the payer if `authorizedSigner` was zero. The helper
supports a positive initial settlement up to the opening deposit, not a
top-up-dependent cumulative amount. It requires `type: tempo` and cannot be
combined with `call`, `calls`, `to`, `input`, or a nonzero `value`.
Settlement pays the cumulative amount but does not close the channel.

## Supported Chains

### Ethereum (`txgen-ethereum`)
Expand Down
84 changes: 79 additions & 5 deletions crates/txgen-cli/src/generate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -946,6 +946,7 @@ enum ResolvedBinding {
U256(U256),
U64(u64),
String(String),
SignedTx { raw: Bytes, tx_hash: B256, sender: Address },
SetupTx { address: Option<Address>, tx_hash: B256, sender: Address, nonce: u64 },
}

Expand Down Expand Up @@ -1214,6 +1215,20 @@ struct SigningPool {
}

impl SigningPool {
fn submit_ready<W: Write>(
&mut self,
sequence: u64,
tx: GeneratedTx,
writer: &mut NdjsonWriter<W>,
) -> Result<()> {
self.drain_available(writer)?;
while self.in_flight >= self.max_in_flight {
self.recv_one(writer)?;
}
self.in_flight += 1;
self.handle_result(SigningResult { sequence, result: Ok(tx) }, writer)
}

fn new(worker_count: usize) -> Result<Self> {
if worker_count == 0 {
bail!("signing worker count must be at least 1");
Expand Down Expand Up @@ -1477,13 +1492,19 @@ where
.checked_add(1)
.ok_or_else(|| eyre::eyre!("sequence instance counter overflowed u64"))?;
let sequence_key = compute_sequence_key(&name, sequence_instance);
let bindings = resolve_sequence_bindings(&sequence.bindings, ctx, setup_bindings)
.wrap_err_with(|| {
format!("failed to resolve bindings for sequence '{name}'")
})?;
let mut bindings =
resolve_sequence_bindings(&sequence.bindings, ctx, setup_bindings)
.wrap_err_with(|| {
format!("failed to resolve bindings for sequence '{name}'")
})?;

for (idx, step) in sequence.steps.iter().enumerate() {
let label = step.name.as_deref().unwrap_or(&step.template);
if let Some(save) = &step.save &&
(save.is_empty() || save.contains('.') || bindings.contains_key(save))
{
bail!("sequence '{name}' step '{label}' has invalid or duplicate save '{save}'");
}
let base = spec
.templates
.get(&step.template)
Expand All @@ -1503,7 +1524,24 @@ where
sequence,
ctx,
)?;
signing_pool.submit(job, writer)?;
if let Some(save) = &step.save {
// The next step may depend on these signed bytes. Sign this
// step now, retaining the pool's ordered, bounded output.
let tx = sign_workload_job::<A>(job)?;
bindings.insert(
save.clone(),
ResolvedBinding::SignedTx {
raw: tx.raw.clone(),
tx_hash: keccak256(&tx.raw),
sender: tx.sender.ok_or_else(|| {
eyre::eyre!("saved transaction has no sender")
})?,
},
);
signing_pool.submit_ready(sequence, tx, writer)?;
} else {
signing_pool.submit(job, writer)?;
}
written += 1;
}
}
Expand Down Expand Up @@ -1852,6 +1890,18 @@ fn binding_to_value(
(ResolvedBinding::U256(value), None) => Ok(serde_yaml::Value::String(value.to_string())),
(ResolvedBinding::U64(value), None) => Ok(serde_yaml::to_value(value)?),
(ResolvedBinding::String(value), None) => Ok(serde_yaml::Value::String(value.clone())),
(ResolvedBinding::SignedTx { raw, .. }, Some("raw")) => {
Ok(serde_yaml::Value::String(raw.to_string()))
}
(ResolvedBinding::SignedTx { tx_hash, .. }, Some("tx_hash")) => {
Ok(serde_yaml::Value::String(tx_hash.to_string()))
}
(ResolvedBinding::SignedTx { sender, .. }, Some("sender")) => {
Ok(serde_yaml::Value::String(sender.to_string()))
}
(ResolvedBinding::SignedTx { .. }, None) => {
bail!("signed transaction binding '{name}' requires a field");
}
(ResolvedBinding::SetupTx { address: Some(address), .. }, Some("address")) => {
Ok(serde_yaml::Value::String(address.to_string()))
}
Expand Down Expand Up @@ -1918,6 +1968,30 @@ mod tests {
use std::collections::HashMap;
use txgen_core::{derive_mnemonic_signer, GasConfig};

#[test]
fn signed_transaction_bindings_expose_only_offline_outputs() {
let raw = Bytes::from_static(&[0x76, 0x01]);
let hash = keccak256(&raw);
let sender = Address::repeat_byte(7);
let bindings = HashMap::from([(
"opened".to_string(),
ResolvedBinding::SignedTx { raw: raw.clone(), tx_hash: hash, sender },
)]);
for (field, value) in [
("raw", raw.to_string()),
("tx_hash", hash.to_string()),
("sender", sender.to_string()),
] {
assert_eq!(
binding_to_value(&format!("opened.{field}"), &bindings).unwrap(),
serde_yaml::Value::String(value)
);
}
assert!(binding_to_value("opened", &bindings).is_err());
assert!(binding_to_value("opened.receipt", &bindings).is_err());
assert!(binding_to_value("future.raw", &bindings).is_err());
}

struct PendingPrepareAdapter;
struct MutatingKeyAdapter;
struct MutatingKeySignContext;
Expand Down
4 changes: 4 additions & 0 deletions crates/txgen-core/src/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,10 @@ pub struct SequenceStep {
/// Optional human-readable step name for diagnostics.
#[serde(default)]
pub name: Option<String>,
/// Save the signed transaction for subsequent steps as `<save>.raw`,
/// `<save>.tx_hash`, and `<save>.sender`. This does not submit the transaction.
#[serde(default)]
pub save: Option<String>,
/// Template name to instantiate for this step.
pub template: String,
/// Per-step YAML overlay applied over the referenced template.
Expand Down
1 change: 1 addition & 0 deletions crates/txgen-tempo/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ clap.workspace = true

# alloy
alloy-primitives.workspace = true
alloy-sol-types.workspace = true
alloy-consensus.workspace = true
alloy-signer.workspace = true
alloy-network.workspace = true
Expand Down
22 changes: 21 additions & 1 deletion crates/txgen-tempo/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod auth_token_map;
mod mpp;
mod nonce;
mod template;
mod zone;
Expand Down Expand Up @@ -455,7 +456,25 @@ impl NetworkAdapter for TempoAdapter {
}
};

let (to, value, input, calls) = resolve_call_data(&template, is_tempo, ctx)?;
let (to, value, input, calls) = if let Some(settle) = &template.mpp_settle {
if !is_tempo ||
template.call.is_some() ||
template.calls.is_some() ||
template.to.is_some() ||
template.input.is_some() ||
ctx.resolve_value(&template.value)? != U256::ZERO
{
bail!("mpp_settle requires type: tempo and cannot be combined with call/calls/to/input/value");
}
(
TxKind::Create,
U256::ZERO,
Bytes::new(),
vec![mpp::settlement_call(settle, selected.address, ctx)?],
)
} else {
resolve_call_data(&template, is_tempo, ctx)?
};

let mut req = TempoTransactionRequest::default();
req.set_chain_id(ctx.chain_id);
Expand Down Expand Up @@ -1242,6 +1261,7 @@ mod tests {

fn base_template(tx_type: TempoTxType) -> TempoTemplate {
TempoTemplate {
mpp_settle: None,
tx_type,
from: AccountRef { pool: "users".to_string(), select: SelectMode::Index(0) },
gas_limit: 21000,
Expand Down
Loading
Loading