Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/cashc/src/Errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,8 @@ export class BitshiftBitcountNegativeError extends CashScriptError {
}
}

export class InvalidTimelockError extends CashScriptError { }

export class VersionError extends Error {
constructor(
readonly actual: string,
Expand Down
9 changes: 9 additions & 0 deletions packages/cashc/src/ast/AST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,9 +486,18 @@ export class BoolLiteralNode extends LiteralNode<boolean> {
}
}

// Whether an int literal is a number of seconds: written with a time unit (e.g. `30 days`), or folded from one
export enum TimeUnit {
NONE = 'none',
SECONDS = 'seconds',
// Folded from a time unit in a way that leaves no clear unit (e.g. `1 days + 511`), so it cannot be used as a timelock
AMBIGUOUS = 'ambiguous',
}

export class IntLiteralNode extends LiteralNode<bigint> {
constructor(
public value: bigint,
public timeUnit: TimeUnit = TimeUnit.NONE,
) {
super();
this.type = PrimitiveType.INT;
Expand Down
9 changes: 6 additions & 3 deletions packages/cashc/src/ast/AstBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
BinaryOpNode,
BoolLiteralNode,
IntLiteralNode,
TimeUnit,
HexLiteralNode,
StringLiteralNode,
ExpressionNode,
Expand Down Expand Up @@ -94,6 +95,7 @@ import { Location } from './Location.js';
import {
Modifier,
NumberUnit,
TIME_UNITS,
TimeOp,
} from './Globals.js';
import { getPragmaName, PragmaName, getVersionOpFromCtx } from './Pragma.js';
Expand Down Expand Up @@ -529,9 +531,10 @@ export default class AstBuilder
createIntLiteral(ctx: LiteralContext): IntLiteralNode {
const numberCtx = ctx.numberLiteral();
const numberString = numberCtx.NumberLiteral().getText();
const numberUnit = numberCtx.NumberUnit()?.getText();
const numberValue = parseNumberString(numberString) * BigInt(numberUnit ? NumberUnit[numberUnit.toUpperCase()] : 1);
const intLiteral = new IntLiteralNode(numberValue);
const numberUnit = numberCtx.NumberUnit()?.getText().toUpperCase();
const numberValue = parseNumberString(numberString) * BigInt(numberUnit ? NumberUnit[numberUnit] : 1);
const timeUnit = numberUnit !== undefined && TIME_UNITS.includes(numberUnit) ? TimeUnit.SECONDS : TimeUnit.NONE;
const intLiteral = new IntLiteralNode(numberValue, timeUnit);
intLiteral.location = Location.fromCtx(ctx);
return intLiteral;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/cashc/src/ast/Globals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export const NumberUnit: { [index: string]: number } = {
WEEKS: 604800,
};

export const TIME_UNITS = ['SECONDS', 'MINUTES', 'HOURS', 'DAYS', 'WEEKS'];

export enum GlobalFunction {
ABS = 'abs',
MIN = 'min',
Expand Down
2 changes: 2 additions & 0 deletions packages/cashc/src/compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import TypeCheckTraversal from './semantic/TypeCheckTraversal.js';
import EnsureFinalRequireTraversal from './semantic/EnsureFinalRequireTraversal.js';
import EnsureFunctionsSafeTraversal from './semantic/EnsureFunctionsSafeTraversal.js';
import InjectLocktimeGuardTraversal from './semantic/InjectLocktimeGuardTraversal.js';
import ResolveTimelocksTraversal from './semantic/ResolveTimelocksTraversal.js';
import DeadCodeEliminationTraversal from './semantic/DeadCodeEliminationTraversal.js';
import { LowerGlobalConstantsTraversal } from './semantic/LowerGlobalConstantsTraversal.js';

Expand Down Expand Up @@ -127,6 +128,7 @@ function compileCode(
ast = ast.accept(new UnusedCodeWarningsTraversal(warningListener ?? defaultWarningListener)) as Ast;

ast = ast.accept(new TypeCheckTraversal()) as Ast;
ast = ast.accept(new ResolveTimelocksTraversal()) as Ast;
ast = ast.accept(new EnsureFunctionsSafeTraversal()) as Ast;
ast = ast.accept(new EnsureFinalRequireTraversal()) as Ast;
if (mergedCompilerOptions.enforceLocktimeGuard) {
Expand Down
19 changes: 18 additions & 1 deletion packages/cashc/src/generation/GenerateTargetTraversal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,13 @@ import {
ExpressionNode,
} from '../ast/AST.js';
import AstTraversal from '../ast/AstTraversal.js';
import { GlobalFunction, Class } from '../ast/Globals.js';
import { GlobalFunction, Class, TimeOp } from '../ast/Globals.js';
import { BinaryOperator } from '../ast/Operator.js';
import {
compileBinaryOp,
compileCast,
compileNullaryOp,
compileRelativeTimelockCheck,
compileTimeOp,
compileUnaryOp,
} from './utils.js';
Expand Down Expand Up @@ -606,6 +607,12 @@ export default class GenerateTargetTraversal extends AstTraversal {
visitTimeOp(node: TimeOpNode): Node {
const tagStartIndex = this.output.length;
node.expression = this.visit(node.expression);

// this.age values that are not known at compile time are validated at runtime (see ResolveTimelocksTraversal)
if (node.timeOp === TimeOp.CHECK_SEQUENCE && !(node.expression instanceof IntLiteralNode)) {
this.emitRelativeTimelockCheck(node);
}

this.emit(compileTimeOp(node.timeOp), { location: node.location, positionHint: PositionHint.END });

this.requires.push({
Expand All @@ -630,6 +637,16 @@ export default class GenerateTargetTraversal extends AstTraversal {
return node;
}

private emitRelativeTimelockCheck(node: TimeOpNode): void {
this.emit(compileRelativeTimelockCheck(), { location: node.location, positionHint: PositionHint.END });

this.requires.push({
ip: this.getMostRecentInstructionPointer(),
line: node.location.start.line,
message: 'this.age value must be a number of blocks between 0 and 65535 (or a BIP68-encoded relative timelock)',
});
}

visitRequire(node: RequireNode): Node {
node.expression = this.visit(node.expression);

Expand Down
11 changes: 11 additions & 0 deletions packages/cashc/src/generation/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ export function compileTimeOp(op: TimeOp): Script {
return mapping[op];
}

// Verifies that the this.age value on top of the stack is a BIP68 relative timelock, so that none of its bits are ignored
// and it does not disable the timelock. That is the case if its value shifted right by 16 bits is either 0 (a number of
// blocks) or 64 (the type flag for a number of 512-second units), so if (x >> 16) * ((x >> 16) - 64) is 0.
export function compileRelativeTimelockCheck(): Script {
return [
Op.OP_DUP, encodeInt(16n), Op.OP_RSHIFTNUM,
Op.OP_DUP, encodeInt(64n), Op.OP_SUB, Op.OP_MUL,
Op.OP_NOT, Op.OP_VERIFY,
];
}

export function compileCast(from: Type, to: Type, isUnsafe: boolean): Script {
if (isUnsafe) return [];

Expand Down
45 changes: 38 additions & 7 deletions packages/cashc/src/semantic/FoldGlobalConstantsTraversal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
HexLiteralNode,
IdentifierNode,
IntLiteralNode,
TimeUnit,
LiteralNode,
Node,
SourceFileNode,
Expand Down Expand Up @@ -71,7 +72,7 @@ export class FoldGlobalConstantsTraversal extends AstTraversal {
throw new UnsupportedTypeError(node, node.expression.type, PrimitiveType.INT);
}

return withLocation(new IntLiteralNode(-node.expression.value), node);
return withLocation(new IntLiteralNode(-node.expression.value, node.expression.timeUnit), node);
}

visitBinaryOp(node: BinaryOpNode): Node {
Expand Down Expand Up @@ -102,7 +103,7 @@ function foldPlus(node: BinaryOpNode): LiteralNode {
const { left, right } = node;

if (left instanceof IntLiteralNode && right instanceof IntLiteralNode) {
return withLocation(new IntLiteralNode(left.value + right.value), node);
return withLocation(new IntLiteralNode(left.value + right.value, foldedTimeUnit(node.operator, left, right)), node);
}

if (left instanceof StringLiteralNode && right instanceof StringLiteralNode) {
Expand All @@ -127,16 +128,46 @@ function foldIntArithmetic(node: BinaryOpNode): LiteralNode {
throw new DivisionByZeroError(node);
}

switch (operator) {
case BinaryOperator.MINUS: return withLocation(new IntLiteralNode(left.value - right.value), node);
case BinaryOperator.MUL: return withLocation(new IntLiteralNode(left.value * right.value), node);
const value = applyIntArithmetic(node, left.value, right.value);
return withLocation(new IntLiteralNode(value, foldedTimeUnit(operator, left, right)), node);
}

function applyIntArithmetic(node: BinaryOpNode, left: bigint, right: bigint): bigint {
switch (node.operator) {
case BinaryOperator.MINUS: return left - right;
case BinaryOperator.MUL: return left * right;
// Note: BigInt division and modulo truncate towards zero, matching OP_DIV / OP_MOD semantics
case BinaryOperator.DIV: return withLocation(new IntLiteralNode(left.value / right.value), node);
case BinaryOperator.MOD: return withLocation(new IntLiteralNode(left.value % right.value), node);
case BinaryOperator.DIV: return left / right;
case BinaryOperator.MOD: return left % right;
default: throw new InvalidConstantExpressionError(node);
}
}

// The unit of a folded value follows from its operands: a sum or difference of durations is a duration
// (`2 hours + 30 minutes`), scaling a duration keeps it one (`2 * 1 days`, `1 days / 2`), and dividing two durations gives
// a plain ratio (`1 days / 10 minutes` is a number of blocks). Anything else that involves a duration, such as adding a
// plain number to it (`1 days + 511`), is no longer clearly seconds or not.
function foldedTimeUnit(operator: BinaryOperator, left: IntLiteralNode, right: IntLiteralNode): TimeUnit {
if (left.timeUnit === TimeUnit.NONE && right.timeUnit === TimeUnit.NONE) return TimeUnit.NONE;
if (left.timeUnit === TimeUnit.AMBIGUOUS || right.timeUnit === TimeUnit.AMBIGUOUS) return TimeUnit.AMBIGUOUS;

// From here on, each operand is either seconds or plain, and at least one is seconds
const bothSeconds = left.timeUnit === TimeUnit.SECONDS && right.timeUnit === TimeUnit.SECONDS;
switch (operator) {
case BinaryOperator.PLUS:
case BinaryOperator.MINUS:
case BinaryOperator.MOD:
return bothSeconds ? TimeUnit.SECONDS : TimeUnit.AMBIGUOUS;
case BinaryOperator.MUL:
return bothSeconds ? TimeUnit.AMBIGUOUS : TimeUnit.SECONDS;
case BinaryOperator.DIV:
if (bothSeconds) return TimeUnit.NONE;
return left.timeUnit === TimeUnit.SECONDS ? TimeUnit.SECONDS : TimeUnit.AMBIGUOUS;
default:
return TimeUnit.AMBIGUOUS;
}
}

function typeMismatchError(node: BinaryOpNode, expected: PrimitiveType): CashScriptError {
const resultingType = resultingTypeForBinaryOp(node.operator, node.left.type!, node.right.type!);
if (resultingType) return new UnsupportedTypeError(node, resultingType, expected);
Expand Down
19 changes: 6 additions & 13 deletions packages/cashc/src/semantic/InjectLocktimeGuardTraversal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
ContractNode,
FunctionCallNode,
FunctionDefinitionNode,
IntLiteralNode,
Node,
NullaryOpNode,
SourceFileNode,
Expand All @@ -16,9 +15,9 @@ import { Location } from '../ast/Location.js';
import { NullaryOperator } from '../ast/Operator.js';

// Per BCH consensus, `tx.locktime` is only protocol-enforced if at least one input has a non-final
// sequence number. A require(tx.time >= ...) check — or a require(this.age >= ...) with a compile-time
// int literal below 2^31 — forces that non-finality. When a spending path uses `tx.locktime` without
// such a check, we inject a synthetic require(tx.time >= tx.locktime) guard at the start of the function.
// sequence number. A require(tx.time >= ...) or require(this.age >= ...) check forces that non-finality.
// When a spending path uses `tx.locktime` without such a check, we inject a synthetic
// require(tx.time >= tx.locktime) guard at the start of the function.
export default class InjectLocktimeGuardTraversal extends AstTraversal {
// Keep track of which global functions require a locktime guard when called.
private globalFunctionRequiresLocktimeGuard = new Map<FunctionDefinitionNode, boolean>();
Expand Down Expand Up @@ -101,16 +100,10 @@ class LocktimeGuardRequirementAnalyser extends AstTraversal {
}
}

// Note that `require(tx.time >= ...)` checks are always sufficient to enforce the non-finality of the spending input,
// while `require(this.age >= ...)` checks are only sufficient when the operand is a compile-time int literal below 2^31.
// Note that `require(this.age >= ...)` checks also enforce the non-finality of the spending input, since their value is
// always a BIP68 relative timelock without the disable flag (see ResolveTimelocksTraversal).
function isLocktimeCheck(statement: Node): boolean {
if (statement instanceof TimeOpNode) {
if (statement.timeOp === TimeOp.CHECK_LOCKTIME) return true;
if (statement.timeOp === TimeOp.CHECK_SEQUENCE) {
return statement.expression instanceof IntLiteralNode && statement.expression.value < 2_147_483_648;
}
}
return false;
return statement instanceof TimeOpNode;
}

function createLocktimeGuard(funcNode: FunctionDefinitionNode): TimeOpNode {
Expand Down
134 changes: 134 additions & 0 deletions packages/cashc/src/semantic/ResolveTimelocksTraversal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { BIP68_SECONDS_MAX, encodeBip68, isBip68RelativeTimelock } from '@cashscript/utils';
import {
ConstantDefinitionNode,
ExpressionNode,
IdentifierNode,
IntLiteralNode,
Node,
TimeOpNode,
TimeUnit,
} from '../ast/AST.js';
import AstTraversal from '../ast/AstTraversal.js';
import { TimeOp } from '../ast/Globals.js';
import { InvalidTimelockError } from '../Errors.js';
import { cloneConstantValue } from './LowerGlobalConstantsTraversal.js';

const LOCKTIME_MAX = 0xffffffffn;
const LOCKTIME_THRESHOLD = 500_000_000n;

// require(tx.time >= x) and require(this.age >= x) compile to OP_CHECKLOCKTIMEVERIFY and OP_CHECKSEQUENCEVERIFY, which
// only enforce the intended timelock for certain values of x. Values that are known at compile time are validated here,
// and this.age durations written with time units (e.g. `30 days`) are encoded as BIP68 512-second units. this.age values
// that are only known at runtime are validated at runtime instead (see GenerateTargetTraversal).
export default class ResolveTimelocksTraversal extends AstTraversal {
visitTimeOp(node: TimeOpNode): Node {
const literal = resolveCompileTimeInt(node.expression);

if (node.timeOp === TimeOp.CHECK_LOCKTIME) {
if (literal) validateAbsoluteTimelock(literal);
return node;
}

if (!literal) {
ensureNoTimeUnits(node.expression);
return node;
}

node.expression = encodeRelativeTimelock(literal);
return node;
}
}

// Int literals and references to global int constants (which are folded to a literal) are known at compile time
function resolveCompileTimeInt(expression: ExpressionNode): IntLiteralNode | undefined {
if (expression instanceof IntLiteralNode) return expression;
if (!(expression instanceof IdentifierNode)) return undefined;

const definition = expression.symbol?.definition;
if (!(definition instanceof ConstantDefinitionNode) || !(definition.value instanceof IntLiteralNode)) return undefined;

return cloneConstantValue(definition, expression) as IntLiteralNode;
}

function validateAbsoluteTimelock(literal: IntLiteralNode): void {
if (literal.value < 0n || literal.value > LOCKTIME_MAX) {
throw new InvalidTimelockError(literal, `tx.time value must be between 0 and ${LOCKTIME_MAX}, but found ${literal.value}`);
}

// A duration such as `2 hours` is not a UNIX timestamp, and values below 500,000,000 are treated as a block height.
// A value at or above it is a timestamp, even if it is computed from a time unit (e.g. a date plus a duration).
if (literal.timeUnit === TimeUnit.SECONDS && literal.value < LOCKTIME_THRESHOLD) {
throw new InvalidTimelockError(
literal, `tx.time value must be a block height or a UNIX timestamp, but found a duration of ${literal.value} seconds`,
);
}

if (literal.timeUnit === TimeUnit.AMBIGUOUS && literal.value < LOCKTIME_THRESHOLD) {
throw new InvalidTimelockError(
literal,
`tx.time value ${literal.value} is computed from a time unit but is below ${LOCKTIME_THRESHOLD}, `
+ 'so it is neither a block height nor a UNIX timestamp',
);
}
}

function encodeRelativeTimelock(literal: IntLiteralNode): IntLiteralNode {
if (literal.timeUnit === TimeUnit.AMBIGUOUS) {
throw new InvalidTimelockError(
literal,
`this.age value ${literal.value} is computed from a time unit but is not a clear duration or number of blocks, `
+ 'use a duration (e.g. `1 days`), a number of blocks or a BIP68-encoded value',
);
}

if (literal.timeUnit === TimeUnit.SECONDS) {
if (literal.value < 0n || literal.value > BigInt(BIP68_SECONDS_MAX)) {
throw new InvalidTimelockError(
literal, `this.age duration must be between 0 and ${BIP68_SECONDS_MAX} seconds, but found ${literal.value} seconds`,
);
}

const encodedLiteral = new IntLiteralNode(BigInt(encodeBip68({ seconds: Number(literal.value) })));
encodedLiteral.location = literal.location;
return encodedLiteral;
}

if (!isBip68RelativeTimelock(Number(literal.value))) {
throw new InvalidTimelockError(
literal, `this.age value must be a number of blocks between 0 and 65535 (or a BIP68-encoded relative timelock), but found ${literal.value}`,
);
}

// A fresh literal, like the encoded duration above: a literal that still refers to its global constant is lowered to
// a constant function call later on, which code generation would treat as a runtime value and check again
const validatedLiteral = new IntLiteralNode(literal.value);
validatedLiteral.location = literal.location;
return validatedLiteral;
}

// Durations can only be encoded at compile time, so a runtime this.age value cannot contain any time units
function ensureNoTimeUnits(expression: ExpressionNode): void {
const finder = new TimeUnitFinder();
finder.visit(expression);

if (finder.timeUnitLiteral) {
throw new InvalidTimelockError(
finder.timeUnitLiteral, 'this.age duration must be a single literal or constant (e.g. `30 days`) to be encoded at compile time',
);
}
}

class TimeUnitFinder extends AstTraversal {
timeUnitLiteral?: IntLiteralNode;

visitIntLiteral(node: IntLiteralNode): Node {
if (node.timeUnit !== TimeUnit.NONE) this.timeUnitLiteral ??= node;
return node;
}

visitIdentifier(node: IdentifierNode): Node {
const literal = resolveCompileTimeInt(node);
if (literal && literal.timeUnit !== TimeUnit.NONE) this.timeUnitLiteral ??= literal;
return node;
}
}
Loading
Loading