livepeer / livepeer/external-signer
feat(turnkey): sign TicketBroker deposit/unlock/withdraw via SignTransaction for eth.tx policy gating
- Dominant language
- Go
- Stars
- 1
- Forks
- 0
- PR merge metrics
- No merged PRs in 30d
Description
## Context
Broadcasting-fund ops from go-livepeer (`fundDeposit` / `fundDepositAndReserve` / `fundReserve`, `unlock` / `cancelUnlock`, `withdraw`) are normal EIP-1559 contract calls on TicketBroker (often **payable**). With `-ethExternalSigner`, go-livepeer builds the tx and asks the sidecar for `eth_signTransaction`; the node still broadcasts.
Today the sidecar already implements that RPC:
```133:163:internal/web3signer/server.go
// eth_signTransaction(SendTxArgs) — returns the raw RLP-encoded signed tx.
...
ethSigner := types.LatestSignerForChainID(s.chainID)
sig, err := s.signer.SignDigest(ethSigner.Hash(tx).Bytes())
...
signed, err := tx.WithSignature(ethSigner, txSig)
...
return hexutil.Bytes(raw), nil
```
The Turnkey backend only exposes digest signing:
```62:71:internal/turnkey/turnkey.go
func (t *turnkeySigner) SignDigest(digest []byte) ([]byte, error) {
...
r, s, v, err := t.signRawPayload(hex.EncodeToString(digest))
```
i.e. `ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2` + `HASH_FUNCTION_NO_OP`.
## Why this is a gap for deposit / unlock / withdraw
Functionally, digest-signing the tx sighash can produce a valid TicketBroker tx (chain ID is applied by `LatestSignerForChainID` locally, so we **do not** hit go-livepeer’s in-tree `MarshalBinary` / missing-`ChainID` Turnkey pre-parse bugs).
But Turnkey never sees the transaction. Policies that constrain spend — the whole point of the enclave custody ladder in the README — **cannot** use `eth.tx.to` / `eth.tx.data` / `eth.tx.chain_id` / `eth.tx.value` for these ops. A host that compromises the sidecar API key can ask it to sign an arbitrary 32-byte digest and drain deposit/reserve elsewhere.
In-tree go-livepeer Turnkey (`-turnkeyOrg`) uses `SignTransaction` for `SignTx`, which *does* allow policies like:
```text
activity.action == 'SIGN'
&& eth.tx.to == '0xa8bb618b1520e284046f3dfc448851a1ff26e41b'
&& eth.tx.data[0..10] == '0x511f4073'
&& eth.tx.chain_id == 42161
```
Related bootstrap/policy work: #1. That issue’s TicketBroker `create_policy` example only helps if txs go through `SignTransaction`.
## TicketBroker selectors (Arbitrum One Broker `0xa8bb618b1520e284046f3dfc448851a1ff26e41b`)
| CLI / client | Solidity | Selector |
|---|---|---|
| deposit broadcasting funds | `fundDepositAndReserve` | `0x511f4073` |
| (also) | `fundDeposit` / `fundReserve` | `0x6caa736b` / `0x6f9c3c8f` |
| unlock broadcasting funds | `unlock` | `0xa69df4b5` |
| cancel unlock | `cancelUnlock` | `0xc2c4c2c8` |
| withdraw broadcasting funds | `withdraw` | `0x3ccfd60b` |
Payable calls must preserve `value` in `SendTxArgs` (go-livepeer’s web3signer `AccountManager` already maps `tx.Value()`).
## Proposed work
1. **Extend the Turnkey backend** with a path that calls `ACTIVITY_TYPE_SIGN_TRANSACTION_V2` for `eth_signTransaction`, while keeping `SignRawPayload` for `eth_sign` / `eth_signTypedData` (tickets / EIP-712).
2. **Serialize unsigned EIP-1559 RLP correctly for Turnkey** (type byte + fields **without** empty `yParity`/`r`/`s`; stamp `chainID` from sidecar `-chainID` when the tx omits it). Port the fixes from go-livepeer `eth/turnkey.go` (`marshalUnsignedEthereumTx` / `effectiveTxChainID`).
3. **Optional interface split**: e.g. `TxSigner` vs `DigestSigner`, or detect capability on the backend so `fake` stays digest-only.
4. **Tests**: `eth_signTransaction` for DynamicFee + `value` + TicketBroker calldata (`fundDepositAndReserve`, `unlock`, `withdraw`); assert unsigned payload shape sent to a mocked Turnkey client; round-trip recover/`WithSignature` parity with local signing.
5. **Docs + #1 bootstrap**: document that broadcasting-fund policies use `eth.tx.*` on `SignTransaction`, while PM ticket signing still needs a separate digest/`SIGN` allow. Provision policies for the selectors above (or an allowlist on TicketBroker `to` + `chain_id`).
## Acceptance criteria
- [ ] TicketBroker deposit / unlock / withdraw signed via Turnkey `SignTransaction` when using `-backend turnkey`.
- [ ] A Turnkey policy restricting `eth.tx.to` + selector (or contract allowlist) **allows** those ops and **denies** an arbitrary transfer to another address with the same API key.
- [ ] `eth_sign` / typed-data / ticket digests remain on `SignRawPayload` + `HASH_FUNCTION_NO_OP`.
- [ ] Regression tests cover payable EIP-1559 `eth_signTransaction`.
- [ ] README custody ladder claim (“Ethereum spend policy”) matches the implementation for broadcasting funds.
## Out of scope
- Wiring `-ethExternalSigner` into go-livepeer main (tracked on `rs/external-signer` / related PRs).
- Bootstrap scripts themselves (#1) — but this issue unblocks meaningful `eth.tx` policy templates there.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start with internal/turnkey/turnkey.go and internal/web3signer/server.go, then compare the unsigned transaction serialization in go-livepeer eth/turnkey.go. Trace eth_signTransaction for DynamicFee transactions, including value and TicketBroker calldata, and add tests around the mocked Turnkey request and signature round trip. Done means TicketBroker operations use transaction signing while digest and typed-data flows remain on SignRawPayload, with the README matching the policy behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- api, backend, blockchain, security, testing
- Issue type
- Feature
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100