Guardian's review of OneSig on Canton for LayerZero, published September 2026. The report records 32 findings across 3 review rounds, including 5 medium and 23 low.
- Published
- Review window
- July 1 to September 4, 2026
- Rounds
- Main Review, Remediation Review 1, Remediation Review 3
- Language
- DAML
- Chains
- Canton
- Sector
- Wallets and custody, Governance
- 0 Critical
- 0 High
- 5 Medium
- 23 Low
- 4 Informational
Scope
Findings 32
Main Review
24 findings · July 1 to 6, 2026-
M-01 Medium CallContext collisions swap signed payloads Signatures Resolved
Description
encodeCallContextdoes not uniquely encode aCallContext. It encodes each nested map entry as the encoded key followed immediately by the encoded child value. The key is length-prefixed, but the child subtree is not. The map also does not include an entry count. SinceCallContextis a six-level nested map, two different trees can produce the same encoded text. This matters becauseOpCreateRequestandOpCreateOAppRequestsignlengthPrefix (encodeCallContext callContext)andlengthPrefix (encodeCallContext callbackContext). The outer length prefix only bounds the full encoded context. It does not prove that the encoded context has one unique structured interpretation. Therefore, a quorum can sign one request context while an executor submits a differentCallContextvalue with the same encoding and the same OneSig leaf. A minimal collision shows the problem. Assume two different function-argument maps inside the same surroundingCallContext:Context A: function "1:a4:text" has no args function "a" has arg "text" = AV_Text "a" Context B: function "1:a4:text" has arg "a" = AV_Text "text" function "a" has no argsThese maps are not equal. However, both encode to the same text:
9:1:a4:text1:a4:text4:text1:aThe collision happens because the encoder does not mark where the empty child map for function
"1:a4:text"ends or where the next sibling function entry begins. InContext A, the bytes after9:1:a4:textstart a new function entry named"a". InContext B, those same bytes are interpreted as an argument named"a"inside function"1:a4:text". The signed bytes are identical, so the signature is still valid even though the submitted structured context is different. The callback context is a realistic sink for this bug. The request factory validatescallContextas a single request call, but it only size-checkscallbackContext, stores it on theRequestand later forwards the stored value intoPendingCallback. Callback code such asparseCallbackContextToFnArgsthen consumes the submitted Daml structure, not a re-decoded canonical byte string. A malicious executor can therefore get a quorum signature over one structured callback context while causing a different structured callback context with the same encoded bytes to be stored for downstream callback handling. This does not require forging signatures. The executor relies onContext A /= Context BwhileencodeCallContext Context A == encodeCallContext Context B. The signatures verify because OneSig checks the encoded bytes. The downstream code then uses the submitted Daml value, which can contain different callback arguments than the signers intended.Recommendation
Make every nested container in
CallContextself-delimiting. Prefix each map with its entry count and prefix each encoded child subtree before concatenating the next sibling entry. Apply this toencodeTextMap,encodeAddressMapandencodeTextMapAnyValue. For example:encodeTextMap encodeValue entries = lengthPrefix (show (length (Map.toList entries))) <> mconcat (map (\(key, value) -> lengthPrefix key <> lengthPrefix (encodeValue value)) (Map.toList entries)) encodeAddressMap encodeValue entries = lengthPrefix (show (length (Map.toList entries))) <> mconcat (map (\(address, value) -> encodeAddress address <> lengthPrefix (encodeValue value)) (Map.toList entries))Mirror the wire-format change in the off-chain signer SDK. Add regression tests proving that distinct nested
CallContextvalues cannot collide at bothencodeCallContextandencodeLeaf. Include a callback-specific regression where a two-entrycallbackContextcollision would otherwise change the arguments parsed byparseCallbackContextToFnArgs. -
M-02 Medium Admin bypasses the OneSig quorum Access Control Acknowledged
Description
OneSigConfigholds the secp256k1 signer set, quorum threshold and seed that OneSig layers as an M-of-N multisig on top of the singleadminparty. The config-mutating choicesOneSigConfig_SetThreshold,OneSigConfig_SetSignerandOneSigConfig_SetSeedarecontroller admin(OneSigConfig.daml:66). A single admin bypasses the quorum to change the signers, quorum, and seed, and to exercise those choices, with no secp256k1 signature. This is a finding only because OneSig advertises the M-of-N property; otherwise it is trusted-admin behavior. Nothing on-ledger enforces decentralization. Severity depends on hosting: High if single-operator, Info if threshold-hosted.Recommendation
Route signer/quorum/seed mutation through a quorum-signed
OneSig_ExecuteTransactionop rather than a directcontroller adminchoice, so the multisig governs its own configuration. If admin override is intended, document it explicitly as the trust model. -
L-01 Low Wrong-ID RBAC authorizes global updates Access Control Resolved
Description
OneSig validates the signed
Scopeagainst the protocol config contract being updated, but it does not validate that the RBAC witness used for authorization belongs to the same full scope. For protocol-scopedOpSetMinFeeandOpSetProtocolLedgerTimeValidityPeriod, the executor suppliesaccessControlCidthrough unsigned execution context. OneSig forwards that CID intoSetMinFeeorSetLedgerTimeValidityPeriodwithout first checking the witness scope. This matters for global request-factory configuration.RequestFactoryConfigexposes its minimum-fee and ledger-time-validity interfaces with scopecategory = _CATEGORY_REQUEST_FACTORY,id = ""andadmin = handler. The downstream RBAC check callsassertRolewithexpectedId = NoneandassertRoletreatsNoneas "skip the ID check". It only checks that the suppliedAccessControlhas the expected admin and category before checking the role assignment. An executor can therefore submit a valid quorum-signed global config update while providing a same-admin, same-categoryAccessControlcontract with an unrelated ID. If that wrong-ID RBAC contract grants the OneSig admin the required role, the global request-factory config update succeeds even though the RBAC contract for the signed empty-ID scope would not authorize it.Recommendation
Before forwarding an executor-supplied
accessControlCidinto RBAC-gated settlement-layer choices, OneSig should fetch theAccessControlinterface view and require its full scope to match the signed target scope. The check should compare admin, category and ID. The downstream global request-factory choices should also passSome ""toassertRoleinstead ofNoneso the empty global ID is checked. Alternatively, changeassertRoleso callers cannot accidentally turn off ID validation. For example, represent "solo-admin mode" separately from "RBAC contract is present but ID should not be checked" or make the expected scope a fullScopevalue rather than separate admin, category and optional ID fields. -
L-02 Low Unsigned config replacement CIDs Validation Acknowledged
Description
OpSetTransferRuleandOpSetRequestFactorydo not bind the replacement contract identity in the signed OneSig call. The executor suppliesnewTransferRuleCidandnewRequestFactoryCidthrough unsignedConfigContextandapplyOAppConfigCallforwards those CIDs into the OApp config setter. The downstream checks only prove that the replacement has a compatible high-level identity. For transfer rules,OftFactoryConfigrequires the suppliedITransferRuleto expose the sameinstrumentIdand to have the instrument admin as a signatory. It does not prove that the supplied rule is the current singleton rule operators intended to install. For request factories, OneSig callsRequestFactory_PublicFetchwith the signedexpectedHandler. The request factory implementation checks that the handler matches, but OneSig does not bind or compare the returnedtreasuryorpermanentObservers. Consequently, a same-handler factory with different economics or observers can satisfy the signed check. This does not bypass the signer quorum for the replacement operation. The quorum still authorized changing the transfer rule or request factory for the signed OApp. The gap is narrower: if an alternate same-instrument transfer rule or same-handler request factory already exists and is disclosed, the executor can choose that alternate dependency even if signers expected a different current contract.Recommendation
Bind a stable replacement identity in the signed call and verify it before exercising the config setter. For
OpSetTransferRule, sign and check a stable current-rule identifier or implementation hash in addition to theinstrumentId. ForOpSetRequestFactory, sign and check the relevant factory view fields, includinghandler,treasuryand a stable representation ofpermanentObservers. If contract IDs are too volatile to sign directly, use a registry-backed current pointer or deterministic metadata hash that the on-ledger code can fetch and compare. The important property is that a signed replacement operation identifies the exact dependency, not just a compatible contract family. -
L-03 Low Unsigned fee config can lower request fees Validation Acknowledged
Description
RequestContext.ownerConfigandOAppRequestContext.requestFactoryConfigare unsigned execution-context fields. They are not included in the OneSig Merkle leaf, but they decide whichRequestFactoryConfigis used whenRequest_CreatechecksminFee.executeCreateRequestforwardsownerConfigdirectly intoRequest_Create. The OApp request code forwardsrequestFactoryConfigintoOApp_CreateRequest, which later passes it toRequest_CreateasownerConfig. The productionRequestFactorythen checks only that the supplied config is controlled by the same handler. It does not prove that the supplied config is the current fee policy for the factory. Consequently, if a stale or duplicate same-handlerRequestFactoryConfigexists, the executor can choose the lower-fee config while submitting the same signed request. The request factory will validate the signedfeeAmountagainst that lowerminFee, then store that lowerminFeeon the createdRequest. This does not let the executor spend admin funds. The executor still pays the signedfeeAmount. The issue is narrower: the OneSig signature does not bind the fee policy used by the request factory, so request creation can use a stale or duplicate config instead of the intended current config.Recommendation
Bind request creation to the current
RequestFactoryConfig, not only to the request factory handler. If contract IDs cannot be signed directly, add a stable config identifier or registry-backed current-config pointer and verify it before callingRequest_Create. At minimum, sign and verify stable fee-policy fields such ashandler,minFeeandledgerTimeValidityPeriod. Apply the same check to bothRequestContext.ownerConfigandOAppRequestContext.requestFactoryConfig. -
L-04 Low Rate-limiter state can be redirected Validation Acknowledged
Description
applyOAppConfigCalltakesrateLimitStateCidfrom the unsignedConfigContextand forwards it into signed rate-limiter operations. This affectsOpRateLimiterSetDefault,OpRateLimiterSetEidOverride,OpRateLimiterRemoveEidOverrideandOpRateLimiterSetState. The signed OneSig leaf binds theOAppId, the operation and the new limit or state values. It does not bind the concreteRateLimiterStatethat should receive the checkpoint or overwrite. Downstream validation only checks that the supplied state is signed by the same OApp admin and exposes the sameOAppId. A second activeRateLimiterStatefor the same OApp therefore passes the check. Consequently, an executor with disclosure of the alternate state can submit a valid quorum-signed rate-limiter update while choosing which same-OApp state contract receives it. For checkpointed config changes, the config is still updated, but the intended state is not checkpointed under the old decay parameters before the new limits take effect. ForOpRateLimiterSetState, the signed overwrite can be applied to the alternate state while the intended state remains unchanged. This does not bypass OneSig quorum checks. The issue is that the quorum authorizes a rate-limiter state transition for an OApp, but the executor-controlled context selects which same-OApp state object receives the transition.Recommendation
Bind OneSig rate-limiter operations to the current state identity, not only to
OAppId. If contract IDs cannot be signed because state contracts rotate, introduce a stablerateLimiterStateIdor registry-backed current pointer per OApp. The config implementation should reject anyrateLimitStateCidthat is not the registered current state. Alternatively, enforce one activeRateLimiterStateperOAppIdand haverequireMatchingRateLimitStatereject non-current duplicates. Batch threading can still use the freshly returned replacement CID after the first validated state operation in a batch. -
L-05 Low Duplicate OApp config can receive updates Validation Acknowledged
Description
fetchAndValidateOAppConfigbinds a signed OApp-config operation to anOAppId, but not to a specific current config contract. The executor suppliesConfigContext.configCidthrough unsigned execution context. The code coerces that CID toIOAppConfig, fetches it and checks that the interface view exposes the signedOAppId. That check is only sufficient if eachOAppIdhas one current config. If a duplicate same-OApp config exists, both contracts can pass validation because the signedOAppIdis unchanged. An executor with disclosure of the duplicate CID can therefore submit a valid quorum-signed config update while choosing which same-OApp config receives the update. Consequently, the OneSig nonce can be consumed and the signed update can be applied to the duplicate config while the intended live config remains unchanged. This does not bypass the signer quorum. The narrower issue is that the quorum authorizes a config change for an OApp identity, but unsigned execution context selects which compatible same-OApp config receives it. The duplicate-config prerequisite limits impact. For canonical OApp configs, the duplicate normally has to be created or left active by the trusted OApp admin or deployment process. If deployment guarantees exactly one current config perOAppId, this becomes a hardening issue around stale or duplicate config disclosure.Recommendation
Bind OApp config operations to the current config identity, not only to
OAppId. If CIDs are too volatile to sign directly, add a stable config identifier or registry-backed current-config pointer and verify it before exercising anyIOAppConfigchoice. Alternatively, make duplicate active configs for the sameOAppIdimpossible. Apply the same current-config check to the interface-specific helpers in this module so allowlist, pause, rate-limiter, ledger-time and generic config updates all enforce the same target-binding rule. -
L-06 Low Duplicate IOApp can receive OApp requests Validation Acknowledged
Description
executeOAppCreateRequestbinds the signed operation to anOAppId, but not to a specificIOAppcontract or config contract. The executor supplies both CIDs throughOAppRequestContext. The code fetches them, checks that each one exposes the signedOAppIdand then exercisesOApp_CreateRequeston the suppliedIOAppwith the supplied config. That check is only sufficient if eachOAppIdhas one currentIOAppand one current config. If a duplicate same-OAppIdpair exists, the executor can submit a valid quorum-signedOpCreateOAppRequestusing the duplicate pair instead of the intended one. The signed payload still verifies because the signedOAppIdis unchanged. Consequently, the createdRequestcan be handled by a different gateway, request factory, treasury or observer set than operators expected.Recommendation
Bind
OpCreateOAppRequestto the currentIOAppand config identity, not only toOAppId. If CIDs are too volatile to sign directly, use a registry-backed current pointer or stable target identifier and verify it before exercisingOApp_CreateRequest. At minimum, include stable target fields in the signed call and compare them on-ledger. Useful fields include the expectedoappGateway, request-factory handler, request-factory treasury and a stable config identifier. -
L-07 Low Duplicate RBAC can receive role updates Validation Acknowledged
Description
fetchAndValidateAccessControlbinds the signed operation to aScope, but not to a specificAccessControlcontract. The executor supplies the target CID throughAccessControlContext.targetContractId. The code fetches that contract, checks that its interface view exposes the signedScopeand then allows the role update to continue. That check is only sufficient if each RBAC scope has one currentAccessControlcontract. If a duplicate same-scope contract exists, the executor can submit a valid quorum-signed grant or revoke operation using the duplicate CID instead of the intended one. The signed payload still verifies because the signedScopeis unchanged. Consequently, a valid role update can be consumed without changing the RBAC contract that operators expected to update. For example, a signedOpRevokeRolescan be applied to an alternate same-scope contract while the intended contract keeps the revoked role active.Recommendation
Bind access-control operations to the current RBAC contract identity, not only to
{category, id, admin}. If CIDs are too volatile to sign directly, add a stableaccessControlIdor registry-backed current pointer to the signed call. OneSig should verify that identity before exercisingAccessControl_GrantRolesorAccessControl_RevokeRoles. Alternatively, reject or make unrepresentable multiple activeAccessControlcontracts for the same scope. Batch threading can still use the freshly produced replacement CID after the first validated role update. -
L-08 Low Stale RBAC witness authorizes updates Access Control Acknowledged
Description
OneSig signs the requested operation and its logical target, but it does not sign the
AccessControlcontract that downstream settlement-layer choices use as their delegated-role witness. InexecuteCall, the executor can supply an optionalaccessControlCidthrough unsigned execution context. That supplied CID is then preferred for OApp request creation, OApp config updates, min-fee updates and ledger-time validity updates. The downstream choices still validate the signed OApp or protocol scope against the target object. However, delegated authorization is checked against the suppliedIAccessControlcontract.assertRoleaccepts an access-control contract when its view matches the expected admin, category and id and when its role map grants the required role to the caller. Consequently, the signed payload does not bind the current RBAC state that should authorize the operation. If the intended access-control contract no longer grants the OneSig admin a delegated role, but a stale same-scope access-control contract still grants it, the executor can submit the signed operation with the stale RBAC CID. The operation then executes against the intended live target even though the intended RBAC witness would reject the caller.Recommendation
Bind every RBAC-gated OneSig operation to the current access-control state for the signed scope. A direct fix is to maintain a singleton or registry-backed current
AccessControlpointer per scope and reject any executor-suppliedaccessControlCidthat is not that current pointer before forwarding it into downstream choices. If access-control contracts must rotate, sign and verify a stable access-control identity or version for the operation. Batch threading can still use the replacement CID produced by a prior RBAC update in the same batch, but only after the first RBAC witness has been proven to be the current state. -
L-09 Low Non-bytes32 proofs verify on Canton Validation Resolved
Description
verifyMerkleProofaccepts proof siblings as arbitraryText.commutativeKeccaksorts the two text values and hashes their concatenation without first requiring both values to be canonical bytes32 hashes.computeMerkleRootthen folds every submitted sibling through that helper. This lets Canton accept a quorum-signed Merkle root that depends on a non-bytes32 proof node. For example, a normalencodeLeafoutput can be paired with a one-byte sibling such as"aa". If the signer quorum signscommutativeKeccak leaf "aa",OneSig_ExecuteTransactionacceptsproof = ["aa"]and consumes the nonce. That root is outside the shared OneSig fixed-width proof format used by the other chain implementations and off-chain proof tooling. The Canton verifier therefore accepts a proof shape that cannot be represented as a normal list of bytes32 Merkle siblings.Recommendation
Reject every proof sibling that is not exactly one canonical bytes32 value before sorting or hashing. Apply the same canonicalization rule to the leaf and expected root or normalize them once and use the normalized values consistently throughout signature verification and proof verification. The pair hash should always be computed over two fixed-width 32-byte values, equivalent to
keccak256(bytes32 || bytes32). -
L-10 Low New executors may miss the anchor DoS Acknowledged
Description
OneSigkeeps its observer list on the stable anchor. The anchor is not archived or recreated during normal execution, so this observer list does not change when a signed batch updatesOneSigConfig.executors. A quorum-signed rotation can therefore remove the current executor and add a new one inOneSigConfigwhile leaving the new executor outsideOneSig.observers. The recreated config is visible to the new executor becauseOneSigConfigobservesexecutors. The anchor remains visible only to its original stakeholders unless it is explicitly disclosed. Consequently, the new executor can be authorized byisAuthorizedExecutorbut still be unable to submitOneSig_ExecuteTransaction, because the submitter cannot exercise an invisible anchor contract. The old executor still observes the anchor, but it no longer satisfies the executor allowlist after the rotation.Recommendation
Ensure any party added to
OneSigConfig.executorscan also observe theOneSiganchor before the rotation takes effect. If the anchor remains immutable, reject executor additions unless the new executor is already an anchor observer or require operational explicit disclosure of the anchor before that executor is expected to submit. A stronger fix is to add a controlled anchor-observer update mechanism or to move executor visibility to a durable delegation contract that can be observed independently of the immutable anchor. -
L-11 Low Signer policy changes revive roots Signatures Acknowledged
Description
OneSigConfig_SetSignerandOneSigConfig_SetThresholdrecreateOneSigConfigwith a changed signer set or quorum, but they preserve the existingnonceandseed.OneSig_ExecuteTransactionlater verifies Merkle-root signatures against the current config before checking the leaf for the current nonce. Consequently, signer-policy maintenance does not revoke outstanding roots. A root that was rejected under the previous policy can become valid after the policy changes, as long as the root still targets the activeoneSigId,admin,nonce,seed, expiry and call bytes. For example, a root with one valid signature is rejected while the config requires a 2-of-2 quorum. If the trusted admin later lowers the threshold to 1 without rotatingseed, the same root and signature can execute. Similarly, a root signed only by an address that was not insignerscan execute after that address is added, even if the signature existed before the signer was authorized.Recommendation
Revoke outstanding roots whenever signer-policy maintenance is meant to change who can authorize pending work. The smallest on-ledger fix is to rotate
seedor another signed-domain version, insideOneSigConfig_SetSignerandOneSigConfig_SetThreshold. If preserving outstanding roots across signer-policy changes is intentional, document it as an explicit invariant. Operational runbooks should require aseedrotation before or during any signer-set or threshold update that is intended to invalidate pending signatures. -
L-12 Low Global mode drops EID usage Logical Error Resolved
Description
OpRateLimiterSetGlobalFlagsupdates the rate-limiter config directly and returns no updated rate-limiter state. The OneSig call does not requireConfigContext.rateLimitStateCid, does not checkpoint the current state and does not migrate existing per-EID buckets whennewUseGlobalStatechanges. This is unsafe when the config switches from per-EID state to global state. Existing usage is stored under each destination endpoint ID. AfteruseGlobalStatebecomesTrue, the rate-limiter state code resolves every endpoint to bucket0. If bucket0does not already contain the aggregate usage, later transfers read a fresh bucket and recover full capacity even though the old per-EID buckets still contain active usage. For example, assume endpointeid = 101has900outbound usage against a1000limit. A signedOpRateLimiterSetGlobalFlagsswitchesuseGlobalStatetoTruewithout touching the state contract. The next read for endpoint101resolves to bucket0, sees no prior usage and reports1000available capacity instead of carrying over the existing900usage.Recommendation
Treat changes to
useGlobalStateas state-affecting rate-limiter updates. Require a currentrateLimitStateCidwhenOpRateLimiterSetGlobalFlagschangesuseGlobalStateand update the state atomically with the config. When switching from per-EID mode to global mode, aggregate the active per-EID buckets into bucket0using the old config before creating the new config. When switching away from global mode, define an explicit policy for splitting, preserving or resetting global usage and make that policy part of the signed operation. -
L-13 Low Noncanonical roots reject proofs Validation Resolved
Description
OneSig_ExecuteTransactionuses the submittedmerkleRootin two different forms. The signature payload is built withbuildSignMerkleRootPayload, which treats the root as a bytes32 value and accepts noncanonical text forms such as an optional0xprefix or uppercase hex. The Merkle proof check then passes the raw submittedmerkleRootintoverifyMerkleProof, which compares it directly against the reconstructed root text. Consequently, two text values that represent the same bytes32 root can be treated differently in the same execution. For example,rand"0x" <> rproduce the same EIP-712 signing payload, so the same signatures verify for both. The proof check still fails for"0x" <> r, becausecomputeMerkleRootreconstructs the canonical root textrand compares it against the raw prefixed value. Uppercase roots have the same problem. They are accepted by the signing payload builder as the same bytes, but the proof equality check rejects them when the reconstructed root is lowercase.Recommendation
Canonicalize
merkleRootonce inOneSig_ExecuteTransactionand use that canonical value for both signature verification and Merkle proof verification. If OneSig's intended wire format is strict, reject noncanonical roots before building the signing payload. A safe rule is to require exactly 64 lowercase hex characters with no0xprefix for submitted roots, leaves and proof nodes. -
L-14 Low Proof siblings are not canonicalized Validation Resolved
Description
commutativeKeccaktreats each Merkle node as rawText. It compares the two text values to choose their order and then concatenates the same raw strings before hashing. The proof verifier never first converts the leaf and proof siblings into one canonical bytes32 representation. Consequently, two text values that denote the same 32-byte sibling can be handled differently. A sibling supplied with a0xor0Xprefix introduces non-hex characters into the hash input and aborts verification. An uppercase sibling can also fail even though it denotes the same bytes as the lowercase value, because raw text ordering can put the uppercase value on the opposite side of the pair before hashing.Recommendation
Normalize every Merkle node before sorting and hashing it. Require the leaf, each proof sibling and the expected root to be exactly one canonical bytes32 value or strip an optional prefix and lowercase the value once before comparing or concatenating it. Use the normalized value consistently for signature payload construction and Merkle proof verification.
-
L-15 Low Mixed config updates use stale CIDs DoS Resolved
Description
executeCallthreads replacement config CIDs in separate maps for each logical operation group. OApp config updates useconfigContracts, min-fee updates useminFeeContractsand protocol ledger-time updates useledgerTimeValidityContracts. That works when every consuming update stays inside one operation group. It breaks when the same underlying config contract implements more than one update interface. For example,RequestFactoryConfig,RegistryConfig,DiscoveryRegistryandGovernanceimplement bothIMinFeeandILedgerTimeValidityPeriod. Their interface choices consume the current contract and create a replacement of the same underlying template. Consequently, a valid signer-approved batch can fail even though each call is valid on its own. If the batch first updatesminFeeon aRequestFactoryConfig, OneSig records the replacement only inminFeeContracts. A later ledger-time update for the sameScopedoes not look there. It falls back to the originalledgerTimeValidityContractId, which was archived by the first call, so the whole batch aborts. The reverse order fails in the same way. The same stale-CID behavior exists between OApp config updates and protocol-scoped ledger-time updates for OApp configs such asOftFactoryConfigandAdapterConfig. An OApp config ledger-time update records the replacement only inconfigContracts, while the protocol-scoped ledger-time branch reads onlyledgerTimeValidityContracts.Recommendation
Track replacements by the exact consumed contract ID, not only by logical scope or operation group. After any consuming config update, record a mapping from the old underlying CID to the newly created underlying CID. Before a later branch uses an executor-supplied config CID, rewrite it only when it exactly matches a CID consumed earlier in the same batch. Do not thread replacements only by
ScopeorOAppId. Separate contracts can legitimately share those logical identifiers. Exact CID rewriting preserves that separation while allowing mixed-interface updates on the same consumed config contract to compose. -
L-16 Low Duplicate configs can replay roots Signatures Acknowledged
Description
OneSig_ExecuteTransactiontakes theOneSigConfigto use from the executor-suppliedconfigCid. The helperfetchOwnedConfigonly checks that the fetched config has the sameadminandoneSigIdas the anchor. It does not prove that the supplied config is the single current config for that OneSig instance. The nonce is also local to the supplied config. Execution builds the signed leaf fromconfig.oneSigId,config.admin,config.nonceand the signed calls, verifies the root usingconfig.seed, then archives and recreates only that supplied config withnonce + 1. This means replay protection depends on there being exactly one activeOneSigConfigfor each(admin, oneSigId). If two active configs exist with the sameadmin,oneSigId,nonce,seed, signer set, quorum and executor policy, the same signed root can execute once against each config. The first execution advances the nonce on the first config, but the second config still has the old nonce and still satisfies the ownership check, signature check and Merkle proof check. For example, assume a OneSig instance has two active configsAandB, both at nonce0and both using the same seed and 2-of-2 signer set. The quorum signs a root for a self-management operation at nonce0. An executor can submit that root withconfigCid = A, which advances onlyAto nonce1. The executor can then submit the exact same root, expiry, signatures and transaction withconfigCid = B, becauseBis still at nonce0. The signed operation is consumed twice even though the leaf was intended to be single-use for the OneSig instance.Recommendation
Make the current config unique for each OneSig anchor. Prefer a ledger-enforced singleton keyed by
(admin, oneSigId)if contract keys are acceptable for this package and deployment. If contract keys are not suitable, store or derive a stable current-config pointer for each anchor and haveOneSig_ExecuteTransactionreject any supplied config that is not the current one. The pointer should be updated atomically when execution recreates the config, so nonce advancement is global to the OneSig instance rather than local to an arbitrary matching config CID. -
L-17 Low Malformed seed bricks all executes Validation Resolved
Description
Neither the
OneSigConfigensurenorOneSigConfig_SetSeed(OneSigConfig.daml:117) checks thatseedis a 64-character hex string. The shape is asserted only later, insidebuildSignMerkleRootPayload(assertHexLength), at execute time. A quorum or admin that sets a malformed seed bricks every futureOneSig_ExecuteTransaction, because each execute must first build the sign payload and fails that assertion — including the execute needed to fix the seed. The failure is fail-closed (no forgery), so the harm is a self-inflicted permanent denial of service on the instance.Recommendation
Assert the seed is well-formed (64 hex chars) in the
OneSigConfigensureand inOneSigConfig_SetSeed, so a bad seed is rejected at set time instead of bricking the instance. -
L-18 Low Leaf lengthPrefix counts codepoints Compatibility Acknowledged
Description
lengthPrefixprefixes each text field withshow (T.length value), a Unicode codepoint count (OneSigEncoding.daml:31). The encoding contract requires the off-chain Canton SDK to reproduce the signed leaf byte-for-byte. For any signed field carrying non-ASCII content, an SDK measuring length in UTF-8 bytes or UTF-16 units produces a different length prefix, and therefore a different leaf. On the ledger sidetextToHex(Serde.daml:176) rejects any non-ASCII code point before hashing, so the divergence never yields a valid-but-wrong leaf — there is no forgery. The residual harm is cross-implementation canonicality: a signer whose SDK admits non-ASCII content can produce a root the ledger will not accept (a signed-but-unexecutable batch), and byte-for-byte agreement with the off-chain encoder is otherwise unproven for the codepoint-vs-byte case.Recommendation
Length-prefix by UTF-8 byte length (or fix a single canonical measure and document it), and add a cross-implementation test vector with non-ASCII content to guarantee on-/off-ledger agreement.
-
L-19 Low No upper cap on signers bricks config DoS Acknowledged
Description
The
OneSigConfigensurecaps onlyquorum <= length signers, never a maximum list length (OneSigConfig.daml:44). EveryOneSig_ExecuteTransactionfolds the whole signer list throughverifySignatures, and every setter recreates the full config record. An admin growingsignersorexecutorsone at a time viaSetSigner/SetExecutoreventually pushes the recreated record past the Canton transaction-size limit, at which point both execution and any setter that would shrink the list fail. The instance is bricked with no on-ledger recovery.Recommendation
Add a sane maximum length for
signersandexecutorsto theensureclause so the config can never grow past what a single transaction can carry. -
L-20 Low OneSig anchor lacks ensure on oneSigId Validation Resolved
Description
The
OneSiganchor template has noensureclause (OneSig.daml:31), so it can be created with a negativeoneSigId.OneSigConfig.ensurerequiresoneSigId >= 0, so no matching config can ever be created, andfetchOwnedConfig'sconfig.oneSigId == oneSigIdassertion in every execute reverts. An anchor created with a negativeoneSigIdis permanently unusable. The harm is self-inflicted, since the admin is the sole creator of the anchor, but the asymmetry between the anchor and configensureconstraints has no guard.Recommendation
Add an
ensure oneSigId >= 0(matchingOneSigConfig) to theOneSiganchor template. -
L-21 Low Canton rejects the shared EVM signature format Signatures Acknowledged
Description
OneSig_ExecuteTransactionaccepts signer approvals as[SignerSignature]and passes them directly toverifySignatures. That is not the shared OneSig signature object produced byonesig-core. The shared library emits normal EVM-style recoverable signatures: 65 bytes encoded asr || s || v. The Canton verifier expects a different representation.SignerSignaturecarries a hex SPKI-DER public key and a hex DER ECDSA signature.verifySignaturesthen hashes the EIP-712 payload, verifies the DER signature against the supplied public key, derives the signer's Ethereum address from that public key and checks that the derived address is in the authorized signer set. The signed digest can still be correct. The problem is the submitted signature format. A validonesig-coreapproval for the right root, seed and expiry is rejected by Canton unless the same(r,s)values are re-encoded as DER and paired with the public key before submission. Consequently, the package does not accept the signature object produced by the documented shared OneSig signing library. A Canton executor using canonicalonesig-coreoutput cannot execute otherwise valid OneSig batches unless an additional, canonical conversion layer exists outside the package. This can block material protocol administration on Canton in the cross-chain OneSig signing model.Recommendation
Make the Canton OneSig execution ABI accept the documented shared signature format or provide a canonical Canton adapter and make it part of the required signing interface. If the Daml verifier must keep using
secp256k1WithEcdsaOnly, change the submitted signature representation to carry the public key plus the 65-byte recoverable signature, then convertrandsinto DER before verification. Alternatively, have the official Canton SDK transform aonesig-coreSignatureinto[SignerSignature]deterministically and document that transformed value as the required Canton submission format. -
I-01 Informational Ledger-time updates can hit duplicate configs Validation Acknowledged
Description
OpSetProtocolLedgerTimeValidityPeriodsigns aScope, but the targetledgerTimeValidityContractIdcomes from unsigned execution context.fetchAndValidateLedgerTimeValidityfetches thatILedgerTimeValidityPeriodcontract and checks that its interface view exposes the signed scope with the expected admin signatory. It does not prove that the supplied contract is the current ledger-time config for that scope. This lets a disclosed duplicate config absorb a valid signed update. For example, two activeRequestFactoryConfigcontracts for the same handler both expose the request-factory scope{category = _CATEGORY_REQUEST_FACTORY, id = "", admin = handler}. An executor can submit the signed ledger-time update with the alternate config CID. The alternate contract is consumed and recreated with the newledgerTimeValidityPeriod, while the intended live config keeps its old timestamp-validity rule.Recommendation
Bind protocol-scoped ledger-time updates to the current config identity, not only to
{category, id, admin}. If contract IDs are too volatile to sign directly, add a stable config identifier or registry-backed current pointer and verify it before exercisingSetLedgerTimeValidityPeriod. Also make multiple active ledger-time configs for the same scope unrepresentable or rejected. Batch threading can still use the newly returned CID after a validated update, but the first target in the batch should be proven to be the current config.
Remediation Review 1
7 findings · July 11 to 13, 2026-
M-01 Medium Cross-instance ledger-time auth bypass Access Control Acknowledged
Description
The remediation makes
OneSigConfigimplement the sharedILedgerTimeValidityPeriodinterface, exposing view scope{category = _CATEGORY_ONESIG, id = show oneSigId, admin}(OneSigConfig.daml:70-81). The protocol opOpSetProtocolLedgerTimeValidityPeriodvalidates its executor-supplied target only viafetchAndValidateLedgerTimeValidity->assertSignedScope(OneSig/ILedgerTimeValidityPeriod/Validate.daml:19-27): it checks the signed scope equals the target's view scope and thatadminis a signatory, pluscategory /= OApp. It never checks thatscope.idequals theoneSigIdof theOneSiganchor executing the batch — the leaf is bound to the anchor'soneSigId(encodeLeaf,OneSig.daml:88), but that binding is never compared to the target scope. When one admin operates more than oneOneSiginstance (distinctoneSigId, each with its own EIP-712 signer set/quorum, sharing a Cantonadmin), an executor of instance A can place a call scoped to instance B inside instance A's quorum-signed batch. It routes onto B'sOneSigConfigand mutates B's governedledgerTimeValidityPeriod— even though B's own quorum never approved it. A's quorum thus governs B's fee-staleness window: widen it to accept stale fee timestamps, or narrow it to DoS B's fee path. The attacker is instance A's distinct signer quorum, NOT the admin: this is independent of the accepted "admin bypasses quorum" finding. The admin's authority merely propagates through A's anchor (signatory admin), so a compromise of A's quorum plus an authorized A executor reaches B without B's keys or the admin's Canton key. It requires no duplicate/singleton violation — it works on a correct multi-instance deployment. Confirmed end-to-end with an executable Daml Script PoC (tests/daml/Watchtower/PoC_6a4f6739_e2e.daml): a batch signed only by instance A's quorum keys (leafoneSigId=1), run through the realOneSig_ExecuteTransaction, changed instance B's (oneSigId=2, own distinct quorum)ledgerTimeValidityPeriodfrom 3600 to 99999 with B's keys never used. The different-admin case fails on Daml signatory authority (PoC_6a4f6739.daml).Recommendation
On the protocol ledger-time path, bind the signed scope to the executing instance: require
scope.id == show oneSigIdof theOneSiganchor being exercised, and reject otherwise. AOneSigConfigshould be updated only through its dedicated self-management op (OpSetOneSigLedgerTimeValidityPeriod), never through the settlement-config protocol path. Equivalently, reject the_CATEGORY_ONESIGscope category on the protocol ledger-time path. This fix is independent of the admin-quorum-bypass finding (removing the admin's direct setter would not close this cross-instance path, and vice versa). -
M-02 Medium Old callbacks erase newer rate-limit usage Unexpected Behavior Acknowledged
Description
OpRateLimiterSetDefaultcorrectly checkpoints aggregate usage under the old rate-limit parameters before installing the signed replacement. The callback state for an already pending outflow is not checkpointed or versioned. It keeps onlyscaledAmount,resolvedEid,forwardRecordedandrecordedAt. When that outflow later settles,commitOutflowandreverseOutflowfetch the current config.remainingContributionthen decays the old amount from its originalrecordedAtusing the current limit and window. This applies the new leak rate retroactively to time that elapsed before the OneSig update, even though the aggregate bucket was already checkpointed under the old rate. For example, A records 100 under a 100-second window. Fifty seconds later a signed OneSig update checkpoints A to 50 and changes the window to 1,000 seconds. B then records 50, so the bucket holds 100. Rejecting A recomputes its remaining contribution as 95 under the new window and subtracts it from the bucket. Only 5 remains instead of B's 50. A normal 95-unit outflow can then pass even though the correct remaining capacity is 50. The full proof uses the canonical lock/unlock adapter, its real request and callback lifecycle, the single intended config and state contracts and a signedOpRateLimiterSetDefaultbatch. The gateway rejects A, but an ordinary executor finalizes A after the update and after B records newer usage. No alternate state, duplicate OApp, callback substitution, malicious package or forged signature is involved. This issue requires an in-flight outflow across a legitimate parameter update and favorable finalization order. The resulting accounting error can erase other users' usage and allow cross-chain traffic above the governed limit. Changing to a faster decay rate creates the opposite failure and can leave phantom usage that unnecessarily blocks traffic.Recommendation
Do not calculate an old pending contribution from
recordedAtwith whichever config is active at settlement. Track decay across configuration epochs. One robust design is a cumulative per-bucket leak index: checkpoint the index whenever the limit or window changes, store the index inRecordOutflowResultand subtract onlymax(0, scaledAmount - (currentIndex - recordedIndex))when the callback settles. Alternatively, store pending reservations explicitly and checkpoint their remaining contributions during config changes or prevent parameter activation until pending reservations from the previous epoch settle. Merely storing the old limit and window and applying them forever after the update is not sufficient if the new policy is meant to govern decay after activation. -
M-03 Medium Old callbacks erase post-reset rate-limit usage Unexpected Behavior Acknowledged
Description
IRateLimitState_SetStatereplaces an EID bucket without invalidating receipts returned by earlierIRateLimitState_RecordOutflowcalls. Those receipts contain the recorded amount, EID, direction flag and timestamp. They do not identify the state generation in which the amount was recorded. Consequently,IRateLimitState_ReverseOutflowcan settle a pre-reset receipt against usage recorded after the reset. It subtracts the old receipt's remaining contribution from the current bucket atRateLimiterState.daml:375-405, even though the reset already removed that contribution. The canonical adapter proof first records A's 100-unit outflow and leaves its rejected request pending. A valid quorum then executes signedOpRateLimiterSetStatevalues that reset the bucket to zero. B records 50 units in the new state generation. Any party that can see the pending callback may finalize A's rejection. This changes the bucket from 50 to zero, erasing all of B's usage. A new 100-unit outflow then succeeds even though only 50 units of capacity should be available. The quorum authorizes the exact reset values, but it does not authorize a later executor to remove post-reset usage. The impact is a rate-limit bypass after a legitimate administrative state correction. Exploitation requires an unresolved pre-reset outflow, a signed reset of the same bucket, newer usage and favorable callback ordering.Recommendation
Give each rate-limit bucket a monotonic state generation. Store that generation in
RecordOutflowResultand its callback context. Increment it wheneverSetStatedestructively replaces the bucket.CommitOutflowandReverseOutflowshould modify the bucket only when the receipt generation matches the current generation; an older receipt has already been superseded by the administrative reset and must not be applied again. Alternatively, track every pending reservation and either settle or explicitly invalidate them before a raw overwrite. -
L-01 Low OpSetMinFee same-handler config substitution Access Control Acknowledged
Description
OpSetMinFee validates its executor-supplied
minFeeContractIdonly viafetchAndValidateMinFee->assertSignedScope(OneSig/IMinFee/Validate.daml:14-20): it checkssignedScope == (view minFee).scopeand that the target admin is a signatory. For the GLOBAL-scoped IMinFee targets —RequestFactoryConfig(RequestFactoryConfig.daml:112-117) andDiscoveryRegistry(DiscoveryRegistry.daml:78-81) — the view scope is{category, _GLOBAL_SCOPE_ID, handler}; the id is ALWAYSGLOBAL, never per-instance. The tuple is thus non-injective over multiple same-{category,GLOBAL,handler}config contracts, so an executor supplying a signedOpSetMinFeecould point it at a different same-handler config than intended, settingminFee(fee floor atFeeUtils/Validators.daml:77) on the wrong one -> fee bypass (lower) or request DoS (raise). CORRECTION (re-rated Medium -> Low). This is NOT the cross-instance escape of6a4f6739: that target (OneSigConfig) has a PER-INSTANCE scopeid = show oneSigId, so one instance's quorum reaches another's DISTINCT-scoped config with no duplicate. Here the scope id is fixed GLOBAL, so the attack REQUIRES two same-{category,GLOBAL,handler}config contracts to coexist — and both templates aresignatory handler, so ONLY the handler can create the duplicate; an executor cannot. That makes this the same operationally-gated duplicate-substitution family as H-01 (6a461062, the same RequestFactoryConfig minFee via the request-creation path) and L-81 (6a461534), which are accepted at LOW. Thescope.id == show oneSigIdfix does not apply (GLOBAL != oneSigId).Recommendation
Family-level defense-in-depth (shared with H-01
6a461062and L-816a461534): prevent two same-scope config contracts from coexisting — a uniqueness key on the config Scope if Canton supports it, and/or make config rotation archive the old contract atomically with creating the new one so no substitutable duplicate exists, and/or bind the exact target CID into the signed OpSetMinFee leaf. Do NOT applyscope.id == show oneSigIdhere (the GLOBAL min-fee scope is not per-instance). This is a facet of the accepted substitution family reached via the OpSetMinFee governance path; consider folding into6a461062if the team prefers a single record. -
L-02 Low No upper bound on validity period Validation Acknowledged
Description
(Re-created — replaces 6a4f673e8d2fb776f9783163, which was accidentally set to Invalidated and cannot be reverted. Content unchanged.) The new governed
ledgerTimeValidityPeriodfield onOneSigConfigis constrained by the templateensureonly to be greater than zero (OneSigConfig.daml:64); it has no upper bound. This field caps the maximum permitted age of the caller-supplied timestamp on the executor-funded fee transfer — its purpose is to bound staleness. With no ceiling, a governance action can set the period arbitrarily large, silently neutralizing the staleness protection the field is meant to enforce and allowing fee transfers carrying very old timestamps to be accepted.SetLedgerTimeValidityPeriodperforms the same lower-bound-only check, so an unbounded value also survives every later update. Note: the value is set by a quorum-signed governance action (an executor cannot widen it unilaterally), so impact is a governance-configuration hazard rather than an executor exploit — reasonable to treat as Low or Info.Recommendation
Add an upper-bound check to the
OneSigConfigensure(e.g.ledgerTimeValidityPeriod <= <sane maximum>) and enforce the same ceiling inSetLedgerTimeValidityPeriod, so the staleness window cannot be set beyond a defensible maximum. -
I-01 Informational Misleading OAppIdMismatch error comment Documentation Acknowledged
Description
The doc comment on
errOneSig_OAppIdMismatchstates the OApp identity is read "from theIAllowlistview" and that "the signed allowlist change cannot be applied to it," implying the error is allowlist-specific. In fact the error is raised byassertConfigMatchesOApp(IOAppConfig/Utils.daml:20), which is invoked by six validators: allowlist, rate-limiter, local-transfer-pause, cross-chain-pause,IOAppConfig, and the IOApp create-request path (confirmed acrossIOApp/Validate.daml,IOAppConfig/Utils.daml,IOAppConfig/Fetch.daml). The allowlist-specific wording is stale for a general-purpose OApp-identity guard, and a maintainer reading it could wrongly assume the check only protects allowlist changes.Recommendation
Generalize the comment on
errOneSig_OAppIdMismatchto state that the error covers any OApp-scoped config or request whose view OApp identity does not match the signedoappId— not just allowlist changes — so it accurately reflects the six call sites that raise it. -
I-02 Informational OneSigConfig observers see full config Trust Assumptions Acknowledged
Description
OneSigConfigdeclaresobserver executors, observers, whereobserversis a free-form[Party]list set at create time. Every party in that list can read the entire contract payload, includingsigners,quorum,seed,executors, andnonce. The list is fixed at creation — there is noOneSigConfig_SetObserverschoice — and it is preserved across every self-management recreate, so the disclosure is permanent for the life of the instance. This is the admin's own instance data, and disclosingseeddoes not enable forgery (that requires the secp256k1 signer private keys). However, a party added only for monitoring/observability gains full visibility of the multisig security parameters — the signer set, thequorumthreshold, and the revocationseed— which a deployment may not intend to expose to observers.Recommendation
If observation is only meant for liveness/monitoring, separate observation from full disclosure: expose a minimal view (e.g. instance id and status) to monitoring parties rather than adding them to the
OneSigConfigobserversset. Otherwise, document explicitly that any party placed inobserversis trusted with the full signer set,quorum, and revocationseed.
Remediation Review 3
1 finding · September 4, 2026-
I-01 Informational Signing zero fails a mandatory positive transfer Compatibility Resolved
Description
DiscoveryRegistrysupports registries whoseminFeeis zero: itsSetUrlchoice performs fee validation and transfer only whenminFee > 0. The directOApp_SetDiscoveryUrlroute can therefore update such a registry with no holdings supplied for the fee. The new OneSig route does not preserve that behavior. Before it callsOApp_SetDiscoveryUrl,executeOAppDiscoveryalways callstransferAmuletwith the signedfeeAmount.transferAmuletrequires its amount to be strictly positive. Consequently, when the quorum signs the exact fee required by a registry whose fee is zero—feeAmount = 0—execution aborts at the preliminary transfer from executor to admin before the registry can process the URL update. The failed Daml transaction leaves the discovery URL unchanged. The quorum cannot execute the supported update through OneSig with the correct zero value. A native administrator can still use the direct route. This makes the defect a narrow compatibility and operational liveness issue rather than an asset loss issue.Recommendation
Handle the zero fee case before calling
transferAmulet. Fetch and validate the selected registry. When its currentminFeeis zero, skip the preliminary transfer and pass an empty holdings list toOApp_SetDiscoveryUrl. Preserve the positive transfer path only when a positive registry fee is actually due.
No findings match.
More from LayerZero
All 11 reports-
Canton VER Updates
169 findings2 critical · 12 high 169 findings: 2 critical, 12 high, 36 medium, 54 low, 65 informational -
OneSig on Solana
27 findings 27 findings: 3 low, 24 informational -
Console EVM Updates
4 findings 4 findings: 1 low, 3 informational -
Solana Console
42 findings 42 findings: 6 low, 36 informational
Put your code through the same review.
This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.