chain: discover payer keys by the paired permission, not always active - #590
Conversation
`get_required_keys` hard-coded `<payer>@active` for an explicit `sysio.payer`
entry, while `check_authorization` pairs the marker with ANY real permission the
payer declares on the same action:
if (auth.actor == payer && auth.permission != config::sysio_payer_name) {
foundPayer = true;
The two therefore disagreed whenever the paired permission was not `active`. A
self-pay transaction paired under `owner`, or under a custom permission linked to
the action, is accepted by consensus but was rejected by discovery -- so
/v1/chain/get_required_keys, which clio, kiod and every wallet call to learn which
keys to sign with, could not produce a signable key set for a transaction the chain
would have taken. The divergence stayed hidden wherever owner and active share a
key, which is the default for a freshly created account.
The marker needs no check of its own. It is virtual: no `permission_object` backs
it and it holds no keys. Consensus already requires it to be paired with a real
permission, and that entry is checked on its own iteration of the same loop -- so
skipping the marker makes discovery agree with consensus by construction, rather
than by keeping a second copy of the pairing rule in sync. In the common case the
reported key set is unchanged; where it differs, it was previously wrong.
Not a consensus path: the only callers are the read-only RPC and the snapshot
provider signing its own votesnaphash transaction, so no protocol feature applies.
`get_required_keys_explicit_payer_tests` covers it on `payloadless::doit`, chosen
because it takes no arguments and asserts nothing about its authorization, so the
authorization path is the only thing that can reject these transactions. For each
of the three pairings -- `active`, `owner`, and a linked custom permission -- it
asserts both the key discovery reports AND that a transaction signed with exactly
that key is accepted, since agreement between the two is the property at issue and
either half alone passed before. It also pins that discovery stays strict
(candidates that cannot satisfy the paired permission still throw, including the
neighbouring permissions in the hierarchy) and that the marker itself is attributed
no key, via a two-action transaction pairing the same payer under two permissions.
Verified as a guard: reinstating the `@active` check fails the `owner` and custom
cases.
Change-Id: I66930d2dc0da3339508afde6c129f9d2ab51f02a
| // accepts under `owner` or a linked custom permission was rejected here, so the | ||
| // signing tools that discover keys through /v1/chain/get_required_keys could not | ||
| // sign it. It was masked whenever owner and active shared a key. | ||
| if (declared_auth.permission == config::sysio_payer_name) |
There was a problem hiding this comment.
[P2] Validate payer pairing before skipping the marker
/v1/chain/get_required_keys calls this function directly without running validate_referenced_accounts. With this unconditional continue, an action containing {alice@sysio.payer, bob@active} and only the candidate key for Bob now returns that key even though consensus rejects the transaction because Alice has no real authorization on the same action; a payer-only action can similarly return an empty key set. The base implementation rejected this candidate set while checking alice@active, so this broadens discovery success for transactions that no signature set can authorize.
Please preserve the index, uniqueness, and same-action/same-actor payer checks—ideally through a helper shared with consensus validation—while keeping the marker itself keyless, and add negative coverage for unpaired and cross-action payer declarations.
There was a problem hiding this comment.
Fixed in 86e6523dda, though not with the shared helper -- deliberately.
The three structural rules are now re-checked in get_required_keys itself, mirroring check_authorization: position 0, at most one per action, and paired with a real permission from the same actor on the same action. Exception types and messages match the consensus validator so diagnostics agree.
I kept them duplicated rather than extracting a helper the consensus validators would also call. This function is not on the apply path, and refactoring check_authorization or validate_referenced_accounts to serve it would put a non-consensus caller in a position to change consensus behaviour -- not a trade worth making for a read-only endpoint. The comment records that reasoning so the duplication is not tidied away later.
Negative coverage added for each shape you named: payer paired with a different actor, payer with no real permission (the empty-set case), marker off index 0, two markers on one action, and cross-action pairing. Each asserts BOTH that discovery throws and that consensus rejects the same transaction, so the two cannot drift apart silently -- which is how this bug arose. I verified they fail against the unconditional continue: five failures, exactly the new cases.
One scope note. Two CFA cases are included, and an unpaired CFA marker is deliberately left unscreened: that rule lives in validate_referenced_accounts, not check_authorization, and pulling in one transaction-layer rule while ignoring account existence, permission existence and the rest would be arbitrary. The test asserts that asymmetry so a later change has to update the expectation rather than widen the function silently. Happy to close it if you would rather discovery mirror both layers.
Full sweep green: unit_test 1518, contracts_unit_test 655, plugin_test.
Skipping the sysio.payer marker outright let discovery succeed for pairings
consensus rejects. An action carrying {alice, sysio.payer} with {bob, active}
returned bob's key for a transaction no signature set can authorize, and a
payer-only action returned an empty set that a signing tool reads as "nothing to
sign". /v1/chain/get_required_keys reaches this function directly and never runs
validate_referenced_accounts, so it cannot lean on that earlier gate.
Re-checks the three structural rules that give the marker meaning, mirroring
check_authorization: position 0, at most one per action, and paired with a real
permission from the same actor on the same action. Exception types and messages
match the consensus validator so diagnostics agree.
Deliberately duplicated rather than extracted into a helper shared with the
consensus validators. This function is not on the apply path, and refactoring
the consensus copies to serve it would put a non-consensus caller in a position
to change consensus behaviour. The comment records that reasoning so the
duplication is not tidied away later.
Also corrects the context-free-action comment. A CFA carries either no
authorization at all -- the ordinary case -- or exactly one, and that one only a
payer marker whose actor is already a declared payer in trx.actions. The reason
not to walk them is that consensus does not either: controller passes only
trn.actions to check_authorization. The allowance exists for billing, since
transaction_context::init bills every action including context-free ones to
act.payer(), and the pairing requirement is what stops a signature-less CFA from
naming a payer with nothing authorizing it.
Adds negative coverage for the shapes discovery previously accepted: payer
paired with a different actor, payer with no real permission, marker off index 0,
two markers on one action, and cross-action pairing. Each asserts both that
discovery throws and that consensus rejects the same transaction, so the two
cannot drift apart. Two CFA cases assert the marker contributes no key, and that
an unpaired CFA marker is left to validate_referenced_accounts -- discovery
mirrors check_authorization's rules, not transaction_context's.
Verified the negative cases fail without the fix.
huangminghuang
left a comment
There was a problem hiding this comment.
The original finding is addressed. Please also refresh the PR description so it captures the complete current PR: it still says the marker needs no check and that simply skipping it ensures agreement, which contradicts the follow-up implementation. It also omits the new structural/CFA coverage and retains the old validation count.
| declared_auth ); | ||
| } | ||
|
|
||
| if (!payer.empty()) { |
There was a problem hiding this comment.
[P2] Track payer-marker presence separately
An empty actor is valid JSON input and decodes to account_name{}. For {empty@sysio.payer, bob@active}, assigning the marker actor leaves payer.empty() true, so Bob's key is accepted and this final pairing check is skipped. Both the base implementation and transaction validation reject that input. Track marker presence with an optional/boolean—or explicitly reject an empty actor—and add a regression case.
There was a problem hiding this comment.
Fixed in 42a44037c6 -- and you are right, that was a real hole. account_name{} is what an empty string decodes to, so the marker assigned an empty actor, the sentinel still read as unset, and the pairing check was skipped. Now tracked with an explicit has_payer bool.
Worth noting where consensus sits on this: check_authorization uses the same payer.empty() idiom, but it is never exposed because validate_referenced_accounts rejects the non-existent actor first ("action's paying actor '' does not exist") and runs ahead of it on the apply path. That is precisely why the sentinel holds there and not here. I left the consensus copy alone.
Regression case added, and I verified it fails against the payer.empty() version and passes with the bool.
| BOOST_CHECK_EXCEPTION( required_keys( trx, keys_with_bob ), fc::exception, | ||
| fc_exception_message_starts_with( discovery_msg ) ); | ||
| auto signed_trx = trx; | ||
| signed_trx.sign( active_key, control->get_chain_id() ); |
There was a problem hiding this comment.
[P2] Do not let irrelevant signatures satisfy rejection tests
This helper signs active, owner, and Bob keys for every malformed transaction, although owner is never declared. If the structural guards disappeared, check_authorization would still throw tx_irrelevant_sig, and the broad BOOST_CHECK_THROW(..., fc::exception) would remain green for the wrong reason. Sign only each case's declared real permissions and assert the expected structural exception/message.
There was a problem hiding this comment.
Fixed in 42a44037c6. Fair hit -- that is the failure mode these tests exist to prevent, and I built it into the helper.
It now signs only the real permissions each case declares, derived from the transaction itself, and both halves assert a specific message instead of any fc::exception:
| case | discovery | consensus |
|---|---|---|
| different actor | Payer authorization for |
Payer 'payloadless' did not authorize this action |
| no real permission | Payer authorization for |
same |
| empty actor | Payer authorization for |
action's paying actor '' does not exist |
| off index 0 | Explicit payer must be the first... |
same |
| two markers | Multiple payers specified for action |
action cannot have multiple payers |
| cross-action | Payer authorization for |
Payer 'payloadless' did not authorize this action |
So a case can no longer pass on tx_irrelevant_sig or any other unrelated rejection.
An empty actor is valid JSON and decodes to account_name{}, so using an unset
account_name as the "no payer declared" sentinel misreads
{"" : sysio.payer, bob: active}: the marker assigns an empty actor, the sentinel
still looks unset, the pairing check is skipped, and bob's key comes back for a
transaction consensus rejects. Tracks presence with a bool instead.
Consensus is not exposed to this. check_authorization uses the same idiom but
validate_referenced_accounts rejects the non-existent actor first ("action's
paying actor '' does not exist"), and that pass runs ahead of it on the apply
path. get_required_keys is reached directly by /v1/chain/get_required_keys and
never runs it, which is why the sentinel holds there and not here. The consensus
copy is left alone.
Also tightens the rejection tests. The helper signed active, owner and bob's
keys for every malformed transaction even though owner is never declared, so had
the structural guards been removed check_authorization would have thrown
tx_irrelevant_sig over the unused signature and a broad throw-check would have
stayed green -- rejected for carrying a useless signature rather than for the
malformed payer. It now signs only the real permissions each case declares,
derived from the transaction, and both halves assert the specific message rather
than any fc::exception.
Adds the empty-actor regression case. Verified it fails against the
payer.empty() sentinel and passes with the bool.
huangminghuang
left a comment
There was a problem hiding this comment.
Re-reviewed at 42a4403. The previously reported empty-actor and rejection-test issues are resolved; no remaining code findings.
The mismatch
authorization_managerhas two places that reason about an explicitsysio.payerentry, and they disagreed.Consensus (
check_authorization) pairs the marker with any real permission the payer declares on the same action, then satisfies that entry:Discovery (
get_required_keys) hard-codedactive:So a self-pay transaction paired under
owner, or under a custom permission linked to the action, is accepted by consensus but rejected by discovery. Since/v1/chain/get_required_keysis what clio, kiod and every wallet call to learn which keys to sign with, such a transaction could not be signed through the standard path even though the chain would have taken it.It stayed hidden because a freshly created account usually carries the same key on
ownerandactive. It bites the accounts that separated them — which is the case the doc for this feature actively recommends.The fix
The marker needs no check of its own. It is virtual: no
permission_objectbacks it and it holds no keys. Consensus already requires it to be paired with a real permission, and that entry is checked on its own iteration of the same loop — so skipping the marker makes discovery agree with consensus by construction, rather than by keeping a second copy of the pairing rule in sync.In the common case the reported key set is unchanged. Where it differs, it was previously wrong.
Not a consensus path. The only callers are the read-only RPC and
producer_pluginsigning its ownvotesnaphashtransaction, so no protocol feature applies.Test coverage
get_required_keys_explicit_payer_testsinunittests/api_tests.cpp, onpayloadless::doit— chosen because it takes no arguments and asserts nothing about its authorization, so the authorization path is the only thing that can reject these transactions.For each of the three pairings (
active,owner, and a linked custom permission) it asserts both halves: the key set discovery reports, and that a transaction signed with exactly those keys is accepted. Agreement between the two is the property at issue — either half alone passed before the fix.It also pins:
activesatisfies neither its parentownernor its childcustom), and an empty candidate set;Validation
@activecheck fails theownerand custom cases (2 of the discovery assertions, plus the strictness case whose message differs). The test fails without the fix and passes with it.unit_test --sys-vm— 1516 test cases,*** No errors detected.Note for reviewers
create_accountalready givesownerandactivedistinct keys, so the bug was reachable in the default tester and simply never exercised.This blocks #583 (the ROA overview doc), which documents self-pay under
owner/custom permissions as working end to end. That is true at consensus but was not discoverable before this change, so this should land first.