hiero-ledger / hiero-ledger/hiero-consensus-node

HIP-1215: schedule booked from a DELEGATECALL frame (delegatable_contract_id key) fires on time, then fails INVALID_PAYER_SIGNATURE at execution (testnet)

Open
#27,263 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Java
Stars
406
Forks
226
Avg merge
3d 4h
Merged PRs (30d)
210

Description

## Summary

On Hedera testnet, `scheduleCall` from a contract books a real schedule entity and the network fires
it within ~25 ms of the expiry second — but the scheduled execution fails
`INVALID_PAYER_SIGNATURE` and produces no contract result, so the inner call never reaches the EVM.
The payer is the calling contract, which is what HIP-1215 specifies. The contract's signature is
demonstrably already on the schedule (see the `authorizeSchedule` probe below), so the signature is
accepted at creation and rejected at execution.

The call into `0x16b` is made from a `DELEGATECALL` frame (EIP-2535 diamond → facet → `0x16b`), and
the mirror node shows both schedule entities carry a **`delegatable_contract_id`** admin key, not a
`contractID` one. That is the key family #22862 fixed for `deleteSchedule`; this looks like the same
gap at execution time.

## Environment

- Hedera **testnet**, chain id 296, JSON-RPC relay `https://testnet.hashio.io/api`.
- Observed **2026-09-12**. Our build notes record testnet as consensus node **v0.76 / EVM v0.67**
around this date; we did not read the version off the node itself, so please treat it as
approximate.
- Hedera Schedule Service system contract `0x000000000000000000000000000000000000016b`.
- Caller: an EIP-2535 diamond. The call into `0x16b` therefore originates inside a **`DELEGATECALL`
frame** (diamond → facet → `0x16b`).

## What was done

1. `hasScheduleCapacity(uint256 expirySecond, uint256 gasLimit)` is staticcalled for the intended
pay second at a 2,000,000 gas limit. Returns `true`.
2. The contract calls `scheduleCall(address to, uint256 expirySecond, uint256 gasLimit, uint64 value,
bytes callData)` with `to == address(this)`, `value == 0`, and `callData` encoding a
permissionless `payCoupon(uint256)` on itself — a self-call minutes out (our script's default lead
is 480 s).
3. A schedule entity is created and its address is returned. No `authorizeSchedule` (HIP-755) call
follows — `scheduleCall` self-authorizes.
4. The network fires the schedule at the expiry second.

## What happened

The scheduled transaction executes — mirror node `executed_timestamp` is the expiry second **+ 25 ms**
— and fails **`INVALID_PAYER_SIGNATURE`**. There is **no contract result**, so the inner call never
entered the EVM.

Two independent schedules, created the same way, failed identically:

| Entity | Role |
|---|---|
| `0.0.10505907` | schedule entity, `executed_timestamp` 1789233178.012825052, `INVALID_PAYER_SIGNATURE` |
| `0.0.10506145` | schedule entity, `executed_timestamp` 1789233710.025872208, `INVALID_PAYER_SIGNATURE` |
| `0.0.10504773` | the calling contract, i.e. the schedule payer — a diamond, **since retired** (not the contract currently listed in our README) |

Mirror node (`GET /api/v1/schedules/{id}`) reports for both entities: `payer_account_id`
`0.0.10504773`, `creator_account_id` `0.0.10499114`, `admin_key._type` `ProtobufEncoded` with
`admin_key.key` = `420518c5948105`. Decoded: tag `0x42` is field 8 of `Key`,
`delegatable_contract_id`, containing `ContractID{contractNum = 10504773}`. So the schedule's admin
key is the **DELEGATABLE_CONTRACT_ID** form of the payer contract, not `CONTRACT_ID`.

The payer contract held **20 HBAR** throughout, and its own `admin_key` is a self-referencing
`contractID` key.

That the inner call itself is fine is independently established: the same `payCoupon` invoked
directly by an unprivileged EOA succeeds and moves the funds —
[`0x0a377c80…`](https://hashscan.io/testnet/transaction/0x0a377c8025481788e9c2acf83c4edec56fa2b35bbd5ae2ad2798c44a90f56791).

## The `authorizeSchedule` probe — the signature is already there

The obvious reading is that HIP-1215 books the schedule and HIP-755 must then sign it. That is not
what the network reports. Calling `authorizeSchedule(address)` on the booked schedule, from the same
contract, reverts with the adapter's typed error carrying the system-contract response:

```
HSSCallFailed(0xf0637961, 205)
```

`0xf0637961` is `authorizeSchedule(address)` (confirmed with `cast sig`), and **205 is
`NO_NEW_VALID_SIGNATURES`** — i.e. the contract's signature was already on the schedule and there was
nothing new to add. So the signature is present from creation, and rejected at execution time.

## Interpretation (may well be wrong — that is why this is a question)

The schedule is created from a `DELEGATECALL` frame, so it gets a `delegatable_contract_id` admin
key and, presumably, a payer signature of the same form. If payer-signature verification at scheduled
execution handles `CONTRACT_ID` but not `DELEGATABLE_CONTRACT_ID`, the signature would be accepted at
creation (inside the call frame) and rejected at execution (outside any frame) — which is exactly what
we see, and the same shape as #22862.

`scheduleCallWithPayer` and `executeCallOnPayerSignature` are in the interface and would let an EOA
be the payer; we have not exercised either yet.

## Reproduction

**Note on the sketch below:** it is the minimal *shape* of what we did, not a run we performed. Our
observed failure went through a diamond, i.e. the call into `0x16b` was made from a `DELEGATECALL`
frame. If the root cause is the key type that path produces, a plain contract calling `0x16b`
directly may well succeed — which would itself be a useful data point. To reproduce the delegatecall
path, put `book` behind a minimal proxy that `DELEGATECALL`s into it.

```solidity
interface IHederaScheduleService {
function scheduleCall(address to, uint256 expirySecond, uint256 gasLimit, uint64 value, bytes memory callData)
external returns (int64 responseCode, address scheduleAddress);
function hasScheduleCapacity(uint256 expirySecond, uint256 gasLimit) external view returns (bool);
}

contract Repro {
address constant HSS = address(0x16b);
uint256 public firedAt;

// Anyone may call this; it is the scheduled target.
function ping() external { firedAt = block.timestamp; }

// Fund this contract with HBAR first — it is the schedule's payer.
function book(uint256 expirySecond) external returns (int64 code, address schedule) {
require(IHederaScheduleService(HSS).hasScheduleCapacity(expirySecond, 200_000), "no capacity");
(code, schedule) = IHederaScheduleService(HSS).scheduleCall(
address(this), expirySecond, 200_000, 0, abi.encodeWithSignature("ping()")
);
}
}
```

Deploy, fund with HBAR, call `book(block.timestamp + 480)`, and read the schedule entity on the mirror
node after the expiry second. Through our diamond, that is where the schedule executes on time and
returns `INVALID_PAYER_SIGNATURE` with no contract result, while calling the target directly
succeeds.

Our end-to-end script is
[`scripts/coupon.ts`](https://github.com/dadadave80/tenor/blob/main/scripts/coupon.ts); the full
write-up with the numbers above is
[`docs/GROUND-TRUTH.md` §10](https://github.com/dadadave80/tenor/blob/main/docs/GROUND-TRUTH.md).

## Possibly related

- **#22862** (closed) — "HIP-1215 Scheduled transaction created via a path that involves delegatecall
cannot be deleted": a schedule created through a delegatecall gets a `DELEGATABLE_CONTRACT_ID`
admin key that permission verification did not handle for `deleteSchedule`. Our schedules carry
exactly that key (decoded above), and this failure is also in signature verification — at
execution rather than at delete. Same family, plausibly the same root cause.
- **#26959** (open) — `scheduleCall` returning `INVALID_CONTRACT_ID` on testnet. Different failure and
a different phase. For whatever it is worth as a data point: on 2026-09-12, on our path, **creation
succeeded**.

## Ask

1. Is a **contract** as the scheduled transaction's fee payer expected to work on the current testnet
node when the schedule was created from a `DELEGATECALL` frame, i.e. with a
`delegatable_contract_id` key? Does payer-signature verification at execution handle that key
type, the way #22862 made `deleteSchedule` handle it?
2. If a contract cannot be the payer on this path, is `scheduleCallWithPayer` (with an EOA payer)
the intended path for contract-initiated scheduling, and is `executeCallOnPayerSignature` the
intended variant when the payer signs separately?
3. Either way, could the docs and HIP-1215 state this explicitly? `hasScheduleCapacity` returning
`true` and `scheduleCall` returning a real schedule entity that then fires on time makes an
integrator confident right up to the point where execution fails with an error that points at
signatures rather than at the payer's eligibility. Diamonds and other proxy patterns make the
delegatecall path a common one.

Happy to supply more transaction ids, re-run probes, or test a fix on testnet.

Contributor guide

Open the contributing guide

Research direction

Start with scripts/coupon.ts and docs/GROUND-TRUTH.md §10 to reproduce the DELEGATECALL path through system contract 0x16b, then compare payer-signature execution with the handling added for #22862. Done means the delegatecall-created schedule executes successfully, or the supported payer limitation and required alternative path are documented and tested.

Written by the indexing model from the issue text.

Assessment

Tech stack
java, solidity
Domain
blockchain
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.