Some bugs are not hidden in complicated business logic. They are hidden in code that everyone assumes is too basic to be wrong.
In DAML SDK 2.10.4, DA.Math.sin and DA.Math.cos returned the wrong signs for entire quadrants of the unit circle. The bug was reported upstream in digital-asset/daml#23027, reproduced by the maintainers, and marked as fixed and backported on May 18, 2026.
The important lesson is not just that a trigonometric helper had a bug. It is that standard library code, even code shipped with the compiler, is not automatically mathematically audited. If a smart contract language exposes deterministic math helpers, those helpers become part of the application semantics. A one-character sign error can become a protocol-level accounting error if the output is used for pricing, allocation, scoring, time curves, or risk logic.
This bug lived in the DAML standard library source shipped with damlc, under sdk/compiler/damlc/daml-stdlib-src/DA/Math.daml. It was not an optimizer bug or a ledger runtime bug. It was a pure DAML implementation bug in a library function that the compiler distribution makes available to users.
The Short Version
DA.Math.cos and DA.Math.sin are wrappers around a CORDIC implementation:
cos x = fst $ cordic 34 x
sin x = snd $ cordic 34 x
CORDIC reduces angles into a simpler range, computes cosine and sine there, then restores the signs based on the quadrant.
That last step was wrong.
For angles in the second quadrant, where pi / 2 < r <= pi, the implementation transformed the angle to pi - r, but then applied the wrong signs to the result.
The code effectively did this:
let (c, s) = cordic n (pi - r)
in (c, -s)
The correct signs are:
let (c, s) = cordic n (pi - r)
in (-c, s)
The fix looks tiny, but the behavior change is not. The original code says the cosine stays positive and the sine becomes negative in the second quadrant. Mathematics says the opposite.
Why the Second Quadrant Is Special
Take an angle r in the second quadrant, such as 3pi/4.
The reference angle is:
pi - 3pi/4 = pi/4
At pi/4, cosine and sine are both positive:
cos(pi/4) = 0.707...
sin(pi/4) = 0.707...
But 3pi/4 is in the upper-left part of the unit circle:
cos(3pi/4) = -0.707...
sin(3pi/4) = 0.707...
So when reducing 3pi/4 to pi/4, the cosine sign must flip and the sine sign must remain positive.
The buggy DAML implementation did the reverse:
cos(3pi/4) -> 0.707... wrong
sin(3pi/4) -> -0.707... wrong
This is not floating point drift or a tolerance issue. The values have roughly the right magnitude, but they land in the wrong quadrant.
The Fourth Quadrant Broke Transitively
The upstream issue focused on the fourth quadrant, where the compounded behavior is easiest to miss.
Consider 7pi/4.
Mathematically:
cos(7pi/4) = 0.707...
sin(7pi/4) = -0.707...
The DAML implementation first sees that 7pi/4 > pi, so it subtracts pi and negates both results later. That maps the input to:
7pi/4 - pi = 3pi/4
But 3pi/4 is exactly in the buggy second quadrant path.
The second-quadrant path returns the wrong signs. Then the outer r > pi path negates both of those wrong signs. The result is wrong again:
cos(7pi/4) -> -0.707... wrong
sin(7pi/4) -> 0.707... wrong
The third quadrant still happened to work because subtracting pi from a third-quadrant angle lands in the first quadrant, where the base computation is correct. The fourth quadrant subtracts into the second quadrant, where the bug lives.
The quadrant map looked like this in SDK 2.10.4:
| Quadrant | Angle range | Result |
|---|---|---|
| Q1 | [0, pi/2] | correct |
| Q2 | (pi/2, pi] | cosine and sine signs flipped |
| Q3 | (pi, 3pi/2] | correct |
| Q4 | (3pi/2, 2pi) | cosine and sine signs flipped |
Half the unit circle was affected.
A Minimal Reproduction
The reproducer does not need contracts, templates, choices, parties, authorization, or ledger state. It is just expression evaluation.
import qualified DA.Math as Math
testCosSecondQuadrant =
Math.cos 2.3561944902
-- 3pi/4: should be negative
testSinSecondQuadrant =
Math.sin 2.3561944902
-- 3pi/4: should be positive
testCosFourthQuadrant =
Math.cos 5.4977871438
-- 7pi/4: should be positive
testSinFourthQuadrant =
Math.sin 5.4977871438
-- 7pi/4: should be negative
In SDK 2.10.4, the signs were reversed for these cases.
That simplicity is what makes the bug interesting. The failure is not caused by a bad contract design or a misunderstanding of DAML’s authorization model. It is a math library returning values from the wrong quadrant.
Why This Matters in DAML
DAML is usually discussed in terms of templates, choices, signatories, observers, contract keys, and ledger authorization. That can make pure library functions look less security-relevant than ledger actions.
But pure functions still decide ledger behavior.
If a pure function computes an amount, a threshold, a risk score, an allocation weight, or a time-dependent curve, the ledger will faithfully enforce the wrong result. Determinism does not imply correctness. It only means every participant gets the same answer, even when that answer is wrong.
This is especially relevant in DAML because the language deliberately separates contract authorization from ordinary expression evaluation. A choice can be perfectly authorized and still compute the wrong value before creating, archiving, or exercising contracts.
For example, imagine a model that uses trigonometric functions for any periodic schedule:
let weight = base + amplitude * Math.cos phase
If phase falls in a broken quadrant, the sign error can turn a discount into a surcharge, a positive allocation into a negative adjustment, or a low-risk interval into a high-risk interval. The ledger does not know the business meaning of weight; it only sees the computed Decimal.
That is why standard-library math bugs are not merely academic. They can be application bugs at every call site.
The Compiler Boundary Is Wider Than Code Generation
When people hear compiler bug, they often think of miscompiled source code: the source says one thing and the generated output does another.
This bug is different. The DAML compiler distribution includes the standard library. The affected code was pure DAML source in DA.Math, compiled and shipped as part of the SDK experience. From the user’s perspective, there is not much practical difference between wrong LF generated by the compiler and a compiler-shipped library computing the wrong value. In both cases, correct-looking DAML source produces wrong behavior.
That matters for audits.
Auditors should treat compiler-shipped libraries as trusted dependencies, not as axioms. The more fundamental the function sounds, the more likely developers are to skip tests around it.
The local catalog around this issue showed the same pattern before the upstream report:
cos(pi)returned approximately+1instead of-1.cos(3pi/4)returned a positive value instead of a negative one.sin(3pi/4)returned a negative value instead of a positive one.- The root cause reduced to the second-quadrant sign restoration.
The fourth-quadrant bug in the upstream issue is the transitive version of the same root cause.
The Review Rule
The review rule is simple:
Do not review math helpers only at easy first-quadrant values.
For trigonometry, every test suite should include:
| Category | Example |
|---|---|
| zero | 0 |
| first quadrant | pi/4 |
| boundary | pi/2 |
| second quadrant | 3pi/4 |
| boundary | pi |
| third quadrant | 5pi/4 |
| boundary | 3pi/2 |
| fourth quadrant | 7pi/4 |
| normalization | negative angle and angle greater than 2pi |
The bug survives if the tests only check 0, pi/2, and pi/4. It also survives if a test checks absolute error but forgets the sign.
Good numerical tests need both magnitude and sign.
Where It Goes Wrong
assuming a standard-library function is too basic to test
let x = Math.cos phase
If phase can leave the first quadrant, this needs tests. The fact that Math.cos is in DA.Math does not prove the quadrant logic is correct for your SDK version.
testing only the happy quadrant
Math.cos 0.7853981634
Math.sin 0.7853981634
Both are correct at pi/4, but that does not exercise sign restoration. Bugs in quadrant reduction usually live outside the base interval.
using approximate equality without checking sign-sensitive cases
abs (Math.cos angle) == expectedMagnitude
Magnitude-only tests can hide sign errors. For this bug, the absolute value is approximately right. The sign is the failure.
treating deterministic wrongness as acceptable
Deterministic systems can still be wrong. DAML will consistently compute the same value across participants, but consensus over an incorrect value is still an application failure.
assuming pure code cannot create ledger impact
Pure code decides the arguments to create, the branches before exercise, the values stored in contract fields, and the conditions that gate choices. A pure math bug can become a persistent ledger-state bug as soon as its output is written into a contract.
Workarounds for Affected SDKs
If you are on an affected SDK and cannot upgrade to a version containing the backport, do not use DA.Math.sin or DA.Math.cos directly for arbitrary angles.
The practical options are:
- Normalize the angle into the first quadrant yourself and apply the correct signs explicitly.
- Copy a corrected CORDIC implementation into your own model.
- Avoid trigonometric computation on-ledger and pass in externally computed values with validation appropriate to the application.
The third option is not automatically safer. It moves the trust boundary. If the input comes from an operator, oracle, integration service, or off-ledger process, the DAML model still needs a clear idea of what it can verify.
The Takeaway
This bug was small in code and large in semantic surface.
The incorrect branch was essentially:
in (c, -s)
when it needed to be:
in (-c, s)
That one sign swap broke the second quadrant directly and the fourth quadrant indirectly. Because the standard library shipped with the compiler, every project using that SDK version had access to the buggy implementation.
The broader audit lesson is to test library semantics at the boundaries where their internal reductions change behavior. For sin and cos, that means quadrants. For serialization, it means invalid and boundary values. For authorization helpers, it means absent, duplicated, and unexpected parties. For numeric conversions, it means negative values, maximum values, and values just beyond the representable range.
The code most likely to be trusted by default is the code that most needs cheap, explicit, boring tests.