diff --git a/packages/cashc/src/Errors.ts b/packages/cashc/src/Errors.ts index a4141392d..cf6751901 100644 --- a/packages/cashc/src/Errors.ts +++ b/packages/cashc/src/Errors.ts @@ -347,6 +347,8 @@ export class BitshiftBitcountNegativeError extends CashScriptError { } } +export class InvalidTimelockError extends CashScriptError { } + export class VersionError extends Error { constructor( readonly actual: string, diff --git a/packages/cashc/src/ast/AST.ts b/packages/cashc/src/ast/AST.ts index b8858e23f..628d6f996 100644 --- a/packages/cashc/src/ast/AST.ts +++ b/packages/cashc/src/ast/AST.ts @@ -486,9 +486,18 @@ export class BoolLiteralNode extends LiteralNode { } } +// 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 { constructor( public value: bigint, + public timeUnit: TimeUnit = TimeUnit.NONE, ) { super(); this.type = PrimitiveType.INT; diff --git a/packages/cashc/src/ast/AstBuilder.ts b/packages/cashc/src/ast/AstBuilder.ts index 74a7a2566..89030a4be 100644 --- a/packages/cashc/src/ast/AstBuilder.ts +++ b/packages/cashc/src/ast/AstBuilder.ts @@ -20,6 +20,7 @@ import { BinaryOpNode, BoolLiteralNode, IntLiteralNode, + TimeUnit, HexLiteralNode, StringLiteralNode, ExpressionNode, @@ -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'; @@ -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; } diff --git a/packages/cashc/src/ast/Globals.ts b/packages/cashc/src/ast/Globals.ts index 70c7b4290..d7df17dac 100644 --- a/packages/cashc/src/ast/Globals.ts +++ b/packages/cashc/src/ast/Globals.ts @@ -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', diff --git a/packages/cashc/src/compiler.ts b/packages/cashc/src/compiler.ts index dcc21d97b..2ed1fa567 100644 --- a/packages/cashc/src/compiler.ts +++ b/packages/cashc/src/compiler.ts @@ -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'; @@ -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) { diff --git a/packages/cashc/src/generation/GenerateTargetTraversal.ts b/packages/cashc/src/generation/GenerateTargetTraversal.ts index df6f3cb92..5ff8bbfc3 100644 --- a/packages/cashc/src/generation/GenerateTargetTraversal.ts +++ b/packages/cashc/src/generation/GenerateTargetTraversal.ts @@ -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'; @@ -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({ @@ -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); diff --git a/packages/cashc/src/generation/utils.ts b/packages/cashc/src/generation/utils.ts index b6bac2195..a7601d725 100644 --- a/packages/cashc/src/generation/utils.ts +++ b/packages/cashc/src/generation/utils.ts @@ -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 []; diff --git a/packages/cashc/src/semantic/FoldGlobalConstantsTraversal.ts b/packages/cashc/src/semantic/FoldGlobalConstantsTraversal.ts index a75890077..226af79b9 100644 --- a/packages/cashc/src/semantic/FoldGlobalConstantsTraversal.ts +++ b/packages/cashc/src/semantic/FoldGlobalConstantsTraversal.ts @@ -6,6 +6,7 @@ import { HexLiteralNode, IdentifierNode, IntLiteralNode, + TimeUnit, LiteralNode, Node, SourceFileNode, @@ -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 { @@ -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) { @@ -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); diff --git a/packages/cashc/src/semantic/InjectLocktimeGuardTraversal.ts b/packages/cashc/src/semantic/InjectLocktimeGuardTraversal.ts index 6137d6e3c..160061c32 100644 --- a/packages/cashc/src/semantic/InjectLocktimeGuardTraversal.ts +++ b/packages/cashc/src/semantic/InjectLocktimeGuardTraversal.ts @@ -4,7 +4,6 @@ import { ContractNode, FunctionCallNode, FunctionDefinitionNode, - IntLiteralNode, Node, NullaryOpNode, SourceFileNode, @@ -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(); @@ -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 { diff --git a/packages/cashc/src/semantic/ResolveTimelocksTraversal.ts b/packages/cashc/src/semantic/ResolveTimelocksTraversal.ts new file mode 100644 index 000000000..aebd92cc7 --- /dev/null +++ b/packages/cashc/src/semantic/ResolveTimelocksTraversal.ts @@ -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; + } +} diff --git a/packages/cashc/test/compiler/InvalidTimelockError/this_age_constant_duration_in_expression.cash b/packages/cashc/test/compiler/InvalidTimelockError/this_age_constant_duration_in_expression.cash new file mode 100644 index 000000000..e9588b7a5 --- /dev/null +++ b/packages/cashc/test/compiler/InvalidTimelockError/this_age_constant_duration_in_expression.cash @@ -0,0 +1,7 @@ +int constant TIMEOUT = 1 days; + +contract Test() { + function spend() { + require(this.age >= TIMEOUT * 2); + } +} diff --git a/packages/cashc/test/compiler/InvalidTimelockError/this_age_constant_duration_plus_number.cash b/packages/cashc/test/compiler/InvalidTimelockError/this_age_constant_duration_plus_number.cash new file mode 100644 index 000000000..e586c1c2c --- /dev/null +++ b/packages/cashc/test/compiler/InvalidTimelockError/this_age_constant_duration_plus_number.cash @@ -0,0 +1,7 @@ +int constant CHUNKS = 4194304 + (1 days + 511) / 512; + +contract Test() { + function spend() { + require(this.age >= CHUNKS); + } +} diff --git a/packages/cashc/test/compiler/InvalidTimelockError/this_age_constant_duration_squared.cash b/packages/cashc/test/compiler/InvalidTimelockError/this_age_constant_duration_squared.cash new file mode 100644 index 000000000..0a1890148 --- /dev/null +++ b/packages/cashc/test/compiler/InvalidTimelockError/this_age_constant_duration_squared.cash @@ -0,0 +1,7 @@ +int constant AREA = 1 days * 1 days; + +contract Test() { + function spend() { + require(this.age >= AREA); + } +} diff --git a/packages/cashc/test/compiler/InvalidTimelockError/this_age_constant_duration_too_long.cash b/packages/cashc/test/compiler/InvalidTimelockError/this_age_constant_duration_too_long.cash new file mode 100644 index 000000000..9117e44e2 --- /dev/null +++ b/packages/cashc/test/compiler/InvalidTimelockError/this_age_constant_duration_too_long.cash @@ -0,0 +1,7 @@ +int constant TIMEOUT = 52 weeks + 30 days; + +contract Test() { + function spend() { + require(this.age >= TIMEOUT); + } +} diff --git a/packages/cashc/test/compiler/InvalidTimelockError/this_age_constant_inverse_duration.cash b/packages/cashc/test/compiler/InvalidTimelockError/this_age_constant_inverse_duration.cash new file mode 100644 index 000000000..0125eb70a --- /dev/null +++ b/packages/cashc/test/compiler/InvalidTimelockError/this_age_constant_inverse_duration.cash @@ -0,0 +1,7 @@ +int constant RATE = 1 / 1 days; + +contract Test() { + function spend() { + require(this.age >= RATE); + } +} diff --git a/packages/cashc/test/compiler/InvalidTimelockError/this_age_disable_flag.cash b/packages/cashc/test/compiler/InvalidTimelockError/this_age_disable_flag.cash new file mode 100644 index 000000000..ed832a475 --- /dev/null +++ b/packages/cashc/test/compiler/InvalidTimelockError/this_age_disable_flag.cash @@ -0,0 +1,5 @@ +contract Test() { + function spend() { + require(this.age >= 2147483648); + } +} diff --git a/packages/cashc/test/compiler/InvalidTimelockError/this_age_duration_in_expression.cash b/packages/cashc/test/compiler/InvalidTimelockError/this_age_duration_in_expression.cash new file mode 100644 index 000000000..0ea453c49 --- /dev/null +++ b/packages/cashc/test/compiler/InvalidTimelockError/this_age_duration_in_expression.cash @@ -0,0 +1,5 @@ +contract Test(int extraBlocks) { + function spend() { + require(this.age >= extraBlocks + 1 days); + } +} diff --git a/packages/cashc/test/compiler/InvalidTimelockError/this_age_duration_too_long.cash b/packages/cashc/test/compiler/InvalidTimelockError/this_age_duration_too_long.cash new file mode 100644 index 000000000..77e00e2de --- /dev/null +++ b/packages/cashc/test/compiler/InvalidTimelockError/this_age_duration_too_long.cash @@ -0,0 +1,5 @@ +contract Test() { + function spend() { + require(this.age >= 400 days); + } +} diff --git a/packages/cashc/test/compiler/InvalidTimelockError/this_age_negative.cash b/packages/cashc/test/compiler/InvalidTimelockError/this_age_negative.cash new file mode 100644 index 000000000..fe6bfd5fc --- /dev/null +++ b/packages/cashc/test/compiler/InvalidTimelockError/this_age_negative.cash @@ -0,0 +1,5 @@ +contract Test() { + function spend() { + require(this.age >= -1); + } +} diff --git a/packages/cashc/test/compiler/InvalidTimelockError/this_age_too_many_blocks.cash b/packages/cashc/test/compiler/InvalidTimelockError/this_age_too_many_blocks.cash new file mode 100644 index 000000000..07f3f9a75 --- /dev/null +++ b/packages/cashc/test/compiler/InvalidTimelockError/this_age_too_many_blocks.cash @@ -0,0 +1,5 @@ +contract Test() { + function spend() { + require(this.age >= 65536); + } +} diff --git a/packages/cashc/test/compiler/InvalidTimelockError/tx_time_constant_duration.cash b/packages/cashc/test/compiler/InvalidTimelockError/tx_time_constant_duration.cash new file mode 100644 index 000000000..9e7f283ad --- /dev/null +++ b/packages/cashc/test/compiler/InvalidTimelockError/tx_time_constant_duration.cash @@ -0,0 +1,8 @@ +int constant TIMEOUT = 2 hours; +int constant EXTENDED_TIMEOUT = TIMEOUT + 30 minutes; + +contract Test() { + function spend() { + require(tx.time >= EXTENDED_TIMEOUT); + } +} diff --git a/packages/cashc/test/compiler/InvalidTimelockError/tx_time_constant_height_plus_duration.cash b/packages/cashc/test/compiler/InvalidTimelockError/tx_time_constant_height_plus_duration.cash new file mode 100644 index 000000000..eb250316a --- /dev/null +++ b/packages/cashc/test/compiler/InvalidTimelockError/tx_time_constant_height_plus_duration.cash @@ -0,0 +1,7 @@ +int constant DEADLINE = 800000 + 1 days; + +contract Test() { + function spend() { + require(tx.time >= DEADLINE); + } +} diff --git a/packages/cashc/test/compiler/InvalidTimelockError/tx_time_duration.cash b/packages/cashc/test/compiler/InvalidTimelockError/tx_time_duration.cash new file mode 100644 index 000000000..8b21e72c7 --- /dev/null +++ b/packages/cashc/test/compiler/InvalidTimelockError/tx_time_duration.cash @@ -0,0 +1,5 @@ +contract Test() { + function spend() { + require(tx.time >= 2 hours); + } +} diff --git a/packages/cashc/test/compiler/InvalidTimelockError/tx_time_negative.cash b/packages/cashc/test/compiler/InvalidTimelockError/tx_time_negative.cash new file mode 100644 index 000000000..ccbf545e7 --- /dev/null +++ b/packages/cashc/test/compiler/InvalidTimelockError/tx_time_negative.cash @@ -0,0 +1,5 @@ +contract Test() { + function spend() { + require(tx.time >= -1); + } +} diff --git a/packages/cashc/test/compiler/InvalidTimelockError/tx_time_too_large.cash b/packages/cashc/test/compiler/InvalidTimelockError/tx_time_too_large.cash new file mode 100644 index 000000000..658969466 --- /dev/null +++ b/packages/cashc/test/compiler/InvalidTimelockError/tx_time_too_large.cash @@ -0,0 +1,5 @@ +contract Test() { + function spend() { + require(tx.time >= 4294967296); + } +} diff --git a/packages/cashc/test/generation/fixtures/valid-contract-files/mecenas.ts b/packages/cashc/test/generation/fixtures/valid-contract-files/mecenas.ts index 4a9c8e095..477a986f2 100644 --- a/packages/cashc/test/generation/fixtures/valid-contract-files/mecenas.ts +++ b/packages/cashc/test/generation/fixtures/valid-contract-files/mecenas.ts @@ -17,8 +17,8 @@ export const fixtures: Fixture[] = [ bytecode: // function receive 'OP_4 OP_PICK OP_0 OP_NUMEQUAL OP_IF ' - // require(this.age >= period) - + 'OP_3 OP_ROLL OP_CHECKSEQUENCEVERIFY OP_DROP ' + // require(this.age >= period) (with a runtime check that period is a valid relative timelock) + + 'OP_3 OP_ROLL OP_DUP OP_16 OP_RSHIFTNUM OP_DUP 40 OP_SUB OP_MUL OP_NOT OP_VERIFY OP_CHECKSEQUENCEVERIFY OP_DROP ' // require(tx.inputs.length == 1) + 'OP_TXINPUTCOUNT OP_1 OP_NUMEQUALVERIFY ' // require(tx.outputs[0].lockingBytecode == new LockingBytecodeP2PKH(recipient)) @@ -54,23 +54,28 @@ export const fixtures: Fixture[] = [ // Cleanup + 'OP_NIP OP_NIP OP_NIP OP_ENDIF', debug: { - bytecode: '5479009c63537ab275c3519d00cd0376a9147b7e0288ac7e8802e803c0c676547994527994765579547993a16300cc707c949d6700cc55799d51cdc0c78851cc789d686d6d6d5167547a519d5479a97b88547a547aac77777768', + bytecode: '5479009c63537a76608e76014094959169b275c3519d00cd0376a9147b7e0288ac7e8802e803c0c676547994527994765579547993a16300cc707c949d6700cc55799d51cdc0c78851cc789d686d6d6d5167547a519d5479a97b88547a547aac77777768', logs: [], requires: [ - { ip: 11, line: 3 }, - { ip: 15, line: 7 }, - { ip: 23, line: 10 }, - { ip: 47, line: 19 }, - { ip: 53, line: 21 }, - { ip: 58, line: 22 }, - { ip: 62, line: 23 }, - { ip: 77, line: 28 }, - { ip: 83, line: 29 }, + { + ip: 19, + line: 3, + message: 'this.age value must be a number of blocks between 0 and 65535 (or a BIP68-encoded relative timelock)', + }, + { ip: 20, line: 3 }, + { ip: 24, line: 7 }, + { ip: 32, line: 10 }, + { ip: 56, line: 19 }, + { ip: 62, line: 21 }, + { ip: 67, line: 22 }, + { ip: 71, line: 23 }, + { ip: 86, line: 28 }, + { ip: 92, line: 29 }, ], - sourceMap: '2:4:25:5;;;;;3:28:3:34;;:8::36:1;;7:16:7:32:0;:36::37;:8::39:1;10:27:10:28:0;:16::45:1;:49::84:0;:74::83;:49::84:1;;;:8::86;12:23:12:27:0;13:37:13:58;:27::65:1;14:26:14:38:0;:41::47;;:26:::1;:50::58:0;;:26:::1;18:12:18:23:0;:27::33;;:36::44;;:27:::1;:12;:46:20:9:0;19:31:19:32;:20::39:1;:43::66:0;;::::1;:12::68;20:15:24:9:0;21:31:21:32;:20::39:1;:43::49:0;;:12::51:1;22:31:22:32:0;:20::49:1;:63::84:0;:53::101:1;:12::103;23:31:23:32:0;:20::39:1;:43::54:0;:12::56:1;20:15:24:9;2:23:25:5;;;;:4;27::30::0;;;;28:24:28:26;;:16::27:1;:31::37:0;:8::39:1;29:25:29:26:0;;:28::30;;:8::33:1;27:39:30:5;;;1:0:31:1', - sourceTags: '79:81:sc', + sourceMap: '2:4:25:5;;;;;3:28:3:34;;:8::36:1;;;;;;;;;;;7:16:7:32:0;:36::37;:8::39:1;10:27:10:28:0;:16::45:1;:49::84:0;:74::83;:49::84:1;;;:8::86;12:23:12:27:0;13:37:13:58;:27::65:1;14:26:14:38:0;:41::47;;:26:::1;:50::58:0;;:26:::1;18:12:18:23:0;:27::33;;:36::44;;:27:::1;:12;:46:20:9:0;19:31:19:32;:20::39:1;:43::66:0;;::::1;:12::68;20:15:24:9:0;21:31:21:32;:20::39:1;:43::49:0;;:12::51:1;22:31:22:32:0;:20::49:1;:63::84:0;:53::101:1;:12::103;23:31:23:32:0;:20::39:1;:43::54:0;:12::56:1;20:15:24:9;2:23:25:5;;;;:4;27::30::0;;;;28:24:28:26;;:16::27:1;:31::37:0;:8::39:1;29:25:29:26:0;;:28::30;;:8::33:1;27:39:30:5;;;1:0:31:1', + sourceTags: '88:90:sc', }, - fingerprint: '82af4e70abe6257185f9fa2b9b65377949794a7f3f862a65eb3c61ec6bbff28a', + fingerprint: 'f40a8c4db07700b4be364636158c4e481e5cdc26d6b8c3bd6c6ce6383ef249e9', }, }, ]; diff --git a/packages/cashc/test/generation/fixtures/valid-contract-files/relative_timelocks.ts b/packages/cashc/test/generation/fixtures/valid-contract-files/relative_timelocks.ts new file mode 100644 index 000000000..312783b0e --- /dev/null +++ b/packages/cashc/test/generation/fixtures/valid-contract-files/relative_timelocks.ts @@ -0,0 +1,74 @@ +import { Fixture } from '../../fixture-utils.js'; + +export const fixtures: Fixture[] = [ + { + artifact: { + contractName: 'RelativeTimelocks', + constructorInputs: [{ name: 'period', type: 'int' }], + abi: [ + { name: 'afterBlocks', inputs: [] }, + { name: 'afterConstantBlocks', inputs: [] }, + { name: 'afterDuration', inputs: [] }, + { name: 'afterConstantDuration', inputs: [] }, + { name: 'afterEncodedDuration', inputs: [] }, + { name: 'afterPeriod', inputs: [] }, + ], + bytecode: + // function afterBlocks + 'OP_OVER OP_0 OP_NUMEQUAL OP_IF ' + // require(this.age >= 10) (a number of blocks) + + 'OP_10 OP_CHECKSEQUENCEVERIFY ' + // Cleanup + + 'OP_2DROP OP_DROP OP_1 OP_ELSE ' + // function afterConstantBlocks + + 'OP_OVER OP_1 OP_NUMEQUAL OP_IF ' + // require(this.age >= CLAIM_BLOCKS) (1008 blocks: a ratio of two durations is a number, validated like a literal) + + 'f003 OP_CHECKSEQUENCEVERIFY ' + // Cleanup + + 'OP_2DROP OP_DROP OP_1 OP_ELSE ' + // function afterDuration + + 'OP_OVER OP_2 OP_NUMEQUAL OP_IF ' + // require(this.age >= 1 days) (169 512-second units with the BIP68 type flag) + + 'a90040 OP_CHECKSEQUENCEVERIFY ' + // Cleanup + + 'OP_2DROP OP_DROP OP_1 OP_ELSE ' + // function afterConstantDuration + + 'OP_OVER OP_3 OP_NUMEQUAL OP_IF ' + // require(this.age >= CLAIM_PERIOD) (18 512-second units with the BIP68 type flag) + + '120040 OP_CHECKSEQUENCEVERIFY ' + // Cleanup + + 'OP_2DROP OP_DROP OP_1 OP_ELSE ' + // function afterEncodedDuration + + 'OP_OVER OP_4 OP_NUMEQUAL OP_IF ' + // require(this.age >= 4194473) (already BIP68-encoded) + + 'a90040 OP_CHECKSEQUENCEVERIFY ' + // Cleanup + + 'OP_2DROP OP_DROP OP_1 OP_ELSE ' + // function afterPeriod + + 'OP_SWAP OP_5 OP_NUMEQUALVERIFY ' + // require(this.age >= period) (with a runtime check that period is a valid relative timelock) + + 'OP_DUP OP_16 OP_RSHIFTNUM OP_DUP 40 OP_SUB OP_MUL OP_NOT OP_VERIFY OP_CHECKSEQUENCEVERIFY OP_DROP ' + // Cleanup + + 'OP_1 OP_ENDIF OP_ENDIF OP_ENDIF OP_ENDIF OP_ENDIF', + debug: { + bytecode: '78009c635ab26d75516778519c6302f003b26d75516778529c6303a90040b26d75516778539c6303120040b26d75516778549c6303a90040b26d7551677c559d76608e76014094959169b275516868686868', + sourceMap: '6:4:8:5;;;;7:28:7:30;:8::32:1;6:27:8:5;;;:4;10::12::0;;;;11:28:11:40;:8::42:1;10:35:12:5;;;:4;14::16::0;;;;15:28:15:34;:8::36:1;14:29:16:5;;;:4;18::20::0;;;;19:28:19:40;:8::42:1;18:37:20:5;;;:4;22::24::0;;;;23:28:23:35;:8::37:1;22:36:24:5;;;:4;26::28::0;;;27:8:27:36:1;;;;;;;;;;;26:27:28:5;5:0:29:1;;;;', + logs: [], + requires: [ + { ip: 6, line: 7 }, + { ip: 16, line: 11 }, + { ip: 26, line: 15 }, + { ip: 36, line: 19 }, + { ip: 46, line: 23 }, + { + ip: 62, + line: 27, + message: 'this.age value must be a number of blocks between 0 and 65535 (or a BIP68-encoded relative timelock)', + }, + { ip: 63, line: 27 }, + ], + }, + fingerprint: '53d6e0a49ed257d0f05c3a1da17cfec4aa6e9cd951186cdcee3bfa055dc7ca7b', + }, + }, +]; diff --git a/packages/cashc/test/semantic/InjectLocktimeGuardTraversal.test.ts b/packages/cashc/test/semantic/InjectLocktimeGuardTraversal.test.ts index c99f91139..bdbd08174 100644 --- a/packages/cashc/test/semantic/InjectLocktimeGuardTraversal.test.ts +++ b/packages/cashc/test/semantic/InjectLocktimeGuardTraversal.test.ts @@ -178,7 +178,7 @@ describe('InjectLocktimeGuardTraversal', () => { expect(bytecode.startsWith(GUARD_PREFIX)).toBe(true); }); - it('does not inject when require(this.age >= LITERAL) uses a literal below 2^31', () => { + it('does not inject when require(this.age >= ...) uses a literal', () => { const src = ` contract T() { function spend() { @@ -190,7 +190,7 @@ describe('InjectLocktimeGuardTraversal', () => { expect(compileString(src).bytecode.startsWith(GUARD_PREFIX)).toBe(false); }); - it('still injects when require(this.age >= ...) uses a non-literal operand', () => { + it('does not inject when require(this.age >= ...) uses a runtime value, since it is validated at runtime', () => { const src = ` contract T() { function spend(int minAge) { @@ -199,7 +199,7 @@ describe('InjectLocktimeGuardTraversal', () => { require(x >= 100); } }`; - expect(compileString(src).bytecode.startsWith(GUARD_PREFIX)).toBe(true); + expect(compileString(src).bytecode).not.toContain(GUARD_PREFIX); }); it('does not inject when enforceLocktimeGuard is disabled', () => { diff --git a/packages/cashc/test/valid-contract-files/relative_timelocks.cash b/packages/cashc/test/valid-contract-files/relative_timelocks.cash new file mode 100644 index 000000000..594ea4263 --- /dev/null +++ b/packages/cashc/test/valid-contract-files/relative_timelocks.cash @@ -0,0 +1,29 @@ +int constant CLAIM_PERIOD = 2 hours + 30 minutes; +int constant BLOCKS_PER_DAY = 1 days / 10 minutes; +int constant CLAIM_BLOCKS = BLOCKS_PER_DAY * 7; + +contract RelativeTimelocks(int period) { + function afterBlocks() { + require(this.age >= 10); + } + + function afterConstantBlocks() { + require(this.age >= CLAIM_BLOCKS); + } + + function afterDuration() { + require(this.age >= 1 days); + } + + function afterConstantDuration() { + require(this.age >= CLAIM_PERIOD); + } + + function afterEncodedDuration() { + require(this.age >= 4194473); + } + + function afterPeriod() { + require(this.age >= period); + } +} diff --git a/packages/cashscript/src/Errors.ts b/packages/cashscript/src/Errors.ts index ad4c40e10..66fea0f9d 100644 --- a/packages/cashscript/src/Errors.ts +++ b/packages/cashscript/src/Errors.ts @@ -1,3 +1,4 @@ +import { AuthenticationErrorCommon } from '@bitauth/libauth'; import { Artifact, RequireStatement, Type } from '@cashscript/utils'; import { CallStackEntry, @@ -197,7 +198,7 @@ export class FailedRequireError extends FailedTransactionError { const baseMessage = `${attributedFrame.sourceName}:${lineNumber} Require statement failed at input ${inputIndex} ${context}`; const baseMessageWithRequireMessage = `${baseMessage} with the following message: ${requireStatement.message}`; - const headline = `${requireStatement.message ? baseMessageWithRequireMessage : baseMessage}.`; + const headline = `${requireStatement.message ? baseMessageWithRequireMessage : baseMessage}.${formatFailureReason(libauthErrorMessage)}`; // Compiler-injected guards (e.g. the tx.locktime guard) have no user-written source, so the // extracted statement is empty — the require message fully describes the failure on its own. @@ -210,6 +211,18 @@ export class FailedRequireError extends FailedTransactionError { } } +// A require statement usually fails because its condition is false, but it can also fail for another reason, such as a +// tx.time or this.age check on a transaction without the right locktime or sequence number +const formatFailureReason = (libauthErrorMessage?: string): string => { + if (!libauthErrorMessage || FALSE_CONDITION_ERRORS.some((error) => libauthErrorMessage.includes(error))) return ''; + return `\nReason: ${libauthErrorMessage}`; +}; + +const FALSE_CONDITION_ERRORS: string[] = [ + AuthenticationErrorCommon.failedVerify, + AuthenticationErrorCommon.nonNullSignatureFailure, +]; + const formatFrameContext = (frame: ResolvedFrame, contractName: string, lineNumber: number): string => { if (frame.functionName) { return `in contract ${contractName}, function ${frame.functionName} (${frame.sourceName}, line ${lineNumber})`; diff --git a/packages/cashscript/src/TransactionBuilder.ts b/packages/cashscript/src/TransactionBuilder.ts index da93ace41..9aa0ee01b 100644 --- a/packages/cashscript/src/TransactionBuilder.ts +++ b/packages/cashscript/src/TransactionBuilder.ts @@ -35,6 +35,8 @@ import { validateInput, validateOutput, validateUnlocker, + toSequenceNumber, + validateInputOptions, } from './utils.js'; import { FailedTransactionError, @@ -136,6 +138,7 @@ export class TransactionBuilder { addInputs(utxos: SpendableUtxo[] | UnlockableUtxo[], unlocker?: Unlocker, options?: InputOptions): this { utxos.forEach((utxo) => validateInput(utxo, this.changeLocks)); + utxos.forEach((utxo) => validateInputOptions(unlocker ? options : (utxo as UnlockableUtxo).options)); if ( (!unlocker && utxos.some((utxo) => !isUnlockableUtxo(utxo))) || (unlocker && utxos.some((utxo) => isUnlockableUtxo(utxo))) @@ -352,7 +355,7 @@ export class TransactionBuilder { const inputs: LibauthTransaction['inputs'] = this.inputs.map((utxo) => ({ outpointIndex: utxo.vout, outpointTransactionHash: hexToBin(utxo.txid), - sequenceNumber: utxo.options?.sequence ?? DEFAULT_SEQUENCE, + sequenceNumber: toSequenceNumber(utxo.options?.sequence ?? DEFAULT_SEQUENCE), unlockingBytecode: new Uint8Array(), })); diff --git a/packages/cashscript/src/interfaces.ts b/packages/cashscript/src/interfaces.ts index 793b02e23..d06a7a394 100644 --- a/packages/cashscript/src/interfaces.ts +++ b/packages/cashscript/src/interfaces.ts @@ -35,9 +35,15 @@ export function isStandardUnlockableUtxo(utxo: UnlockableUtxo): utxo is Standard } export interface InputOptions { - sequence?: number; + /** + * The input's sequence number, or a relative timelock (for `require(this.age >= ...)` checks) that is encoded as a + * BIP68 sequence number. Seconds are rounded up to a multiple of 512. Defaults to `0xfffffffe`. + */ + sequence?: number | RelativeTimelock; } +export type RelativeTimelock = { blocks: number } | { seconds: number }; + export interface GenerateUnlockingBytecodeOptions { transaction: Transaction; sourceOutputs: LibauthOutput[]; diff --git a/packages/cashscript/src/utils.ts b/packages/cashscript/src/utils.ts index e8a2744bf..757adcad6 100644 --- a/packages/cashscript/src/utils.ts +++ b/packages/cashscript/src/utils.ts @@ -25,6 +25,8 @@ import { Script, scriptToBytecode, encodeNullDataScript, + encodeBip68, + isBip68RelativeTimelock, } from '@cashscript/utils'; import { Utxo, @@ -42,6 +44,8 @@ import { isContractUnlocker, isP2PKHUnlocker, isPlaceholderUnlocker, + RelativeTimelock, + InputOptions, } from './interfaces.js'; import { VERSION_SIZE, LOCKTIME_SIZE } from './constants.js'; import { @@ -73,6 +77,30 @@ export function validateInput(utxo: Utxo, changeLocks: Record): validateChangeLocks(changeLocks, utxo.token?.category); } +export function validateInputOptions(options?: InputOptions): void { + if (options?.sequence !== undefined) toSequenceNumber(options.sequence); +} + +// A sequence number without the disable flag enables a BIP68 relative timelock, so it may not set any of the bits that +// BIP68 ignores, since that would enforce a different timelock than intended +export function toSequenceNumber(sequence: number | RelativeTimelock): number { + if (typeof sequence === 'object') { + // encodeBip68 returns a final sequence number when neither field is set, which would silently disable the timelock + const { blocks, seconds } = (sequence ?? {}) as { blocks?: unknown; seconds?: unknown }; + if ((blocks === undefined) === (seconds === undefined)) { + throw new Error('A relative timelock needs exactly one of blocks or seconds'); + } + return encodeBip68(sequence); + } + + const hasDisableFlag = Number.isInteger(sequence) && sequence >= 0x80000000 && sequence <= 0xffffffff; + if (!hasDisableFlag && !isBip68RelativeTimelock(sequence)) { + throw new Error(`Sequence number ${sequence} is not a valid BIP68 relative timelock, use { blocks } or { seconds } instead`); + } + + return sequence; +} + // A UTXO/unlocker mismatch would be rejected by the network, so we catch it locally with a descriptive error export function validateUnlocker(utxo: SpendableUtxo, unlocker: Unlocker, inputIndex: number, network: Network): void { const unlockerLockingBytecode = getUnlockerLockingBytecode(unlocker); diff --git a/packages/cashscript/test/TransactionBuilder.test.ts b/packages/cashscript/test/TransactionBuilder.test.ts index 3395bc4b7..1929c6024 100644 --- a/packages/cashscript/test/TransactionBuilder.test.ts +++ b/packages/cashscript/test/TransactionBuilder.test.ts @@ -14,7 +14,7 @@ import { carolTokenAddress, alicePriv, } from './fixture/vars.js'; -import { Network, SpendableUtxo, Utxo } from '../src/interfaces.js'; +import { InputOptions, Network, SpendableUtxo, Utxo } from '../src/interfaces.js'; import { utxoComparator, calculateDust, randomUtxo, randomToken, isNonTokenUtxo, isFungibleTokenUtxo } from '../src/utils.js'; import p2pkhArtifact from './fixture/p2pkh.artifact.js'; import twtArtifact from './fixture/transfer_with_timeout.artifact.js'; @@ -370,6 +370,34 @@ describe('Transaction Builder', () => { expect(transaction.setLocktime(2 ** 32 - 1).locktime).toBe(2 ** 32 - 1); }); + it('should encode relative timelocks and reject sequence numbers with bits that BIP68 ignores', () => { + const mockProvider = new MockNetworkProvider(); + const contract = new Contract(p2pkhArtifact, [carolPkh], { provider: mockProvider }); + const unlocker = contract.unlock.spend(carolPub, new SignatureTemplate(carolPriv)); + const getSequenceNumber = (sequence: InputOptions['sequence']): number => { + const utxo = mockProvider.addUtxo(contract.address, randomUtxo()); + const transaction = new TransactionBuilder({ provider: mockProvider }).addInput(utxo, unlocker, { sequence }); + return decodeTransactionUnsafe(hexToBin(transaction.addOutput({ to: carolAddress, amount: 1_000n }).build())) + .inputs[0].sequenceNumber; + }; + + expect(getSequenceNumber(undefined)).toBe(0xfffffffe); + expect(getSequenceNumber({ blocks: 144 })).toBe(144); + expect(getSequenceNumber({ seconds: 86400 })).toBe(0x4000a9); + expect(getSequenceNumber(0x4000a9)).toBe(0x4000a9); + expect(getSequenceNumber(0xffffffff)).toBe(0xffffffff); + + expect(() => getSequenceNumber(70000)).toThrow('Sequence number 70000 is not a valid BIP68 relative timelock'); + expect(() => getSequenceNumber(-1)).toThrow('Sequence number -1 is not a valid BIP68 relative timelock'); + expect(() => getSequenceNumber({ blocks: 70000 })).toThrow('Expected blocks to be an integer between 0 and 65535'); + + // Plain JavaScript can pass an object without a timelock, which must not silently become a final sequence number + const relativeTimelockError = 'A relative timelock needs exactly one of blocks or seconds'; + expect(() => getSequenceNumber({} as InputOptions['sequence'])).toThrow(relativeTimelockError); + expect(() => getSequenceNumber({ blocks: undefined } as unknown as InputOptions['sequence'])).toThrow(relativeTimelockError); + expect(() => getSequenceNumber({ blocks: 1, seconds: 512 } as InputOptions['sequence'])).toThrow(relativeTimelockError); + }); + it('should not report a failed transaction when the transaction cannot be retrieved after broadcasting', async () => { class LookupFailingMockNetworkProvider extends MockNetworkProvider { async getRawTransaction(): Promise { diff --git a/packages/cashscript/test/e2e/misc/timelocks.test.ts b/packages/cashscript/test/e2e/misc/timelocks.test.ts index afabd8a15..90599710d 100644 --- a/packages/cashscript/test/e2e/misc/timelocks.test.ts +++ b/packages/cashscript/test/e2e/misc/timelocks.test.ts @@ -1,4 +1,12 @@ -import { Contract, SignatureTemplate, ElectrumNetworkProvider, MockNetworkProvider } from '../../../src/index.js'; +import { + Contract, + SignatureTemplate, + ElectrumNetworkProvider, + MockNetworkProvider, + FailedRequireError, + InputOptions, + Unlocker, +} from '../../../src/index.js'; import { bobAddress, bobPub, @@ -11,6 +19,7 @@ import { Network } from '../../../src/interfaces.js'; import { utxoComparator, calculateDust, randomUtxo, isNonTokenUtxo } from '../../../src/utils.js'; import p2pkhArtifact from '../../fixture/p2pkh.artifact.js'; import twtArtifact from '../../fixture/transfer_with_timeout.artifact.js'; +import relativeTimelockArtifact from '../../fixture/relative_timelock.artifact.js'; import { TransactionBuilder } from '../../../src/TransactionBuilder.js'; import { addUtxo, getTxOutputs } from '../../test-util.js'; @@ -85,7 +94,61 @@ describe('Timelocks', () => { }); }); - // bip68 relative timelock - it.todo('test sequence numbers'); - // Get utxo age on chipnet with provider.performRequest('blockchain.utxo.get_info', utxo.txid, utxo.vout); + // Note: the mock network does not check the age of the spent UTXO (BIP68), only the contract's this.age checks + describe.skipIf(Boolean(process.env.TESTS_USE_CHIPNET))('Sequence numbers', () => { + const mockProvider = new MockNetworkProvider(); + const timelockInstance = new Contract(relativeTimelockArtifact, [10n], { provider: mockProvider }); + const invalidPeriodInstance = new Contract(relativeTimelockArtifact, [70000n], { provider: mockProvider }); + + const spend = (instance: Contract, unlocker: Unlocker, sequence?: InputOptions['sequence']): Promise => { + const utxo = mockProvider.addUtxo(instance.address, randomUtxo()); + return new TransactionBuilder({ provider: mockProvider }) + .addInput(utxo, unlocker, { sequence }) + .addOutput({ to: instance.address, amount: utxo.satoshis - 1000n }) + .send(); + }; + + it('should succeed when the sequence number satisfies this.age in blocks', async () => { + await expect(spend(timelockInstance, timelockInstance.unlock.afterBlocks(), { blocks: 10 })).resolves.toBeDefined(); + }); + + it('should fail when the sequence number does not satisfy this.age in blocks', async () => { + const txPromise = spend(timelockInstance, timelockInstance.unlock.afterBlocks(), { blocks: 9 }); + + await expect(txPromise).rejects.toThrow(FailedRequireError); + await expect(txPromise).rejects.toThrow('Reason: Program called an OP_CHECKSEQUENCEVERIFY operation that requires a sequence number greater than the input\'s sequence number.'); + await expect(txPromise).rejects.toThrow('Failing statement: require(this.age >= 10)'); + }); + + it('should fail when the input does not enable a relative timelock', async () => { + const txPromise = spend(timelockInstance, timelockInstance.unlock.afterBlocks()); + + await expect(txPromise).rejects.toThrow(FailedRequireError); + await expect(txPromise).rejects.toThrow('Reason: Program called an OP_CHECKSEQUENCEVERIFY operation requiring the disable flag, but the input\'s sequence number is missing the disable flag.'); + }); + + it('should succeed when the sequence number satisfies this.age in seconds', async () => { + const txPromise = spend(timelockInstance, timelockInstance.unlock.afterDuration(), { seconds: 86400 }); + await expect(txPromise).resolves.toBeDefined(); + }); + + it('should fail when this.age in seconds is spent with a sequence number in blocks', async () => { + const txPromise = spend(timelockInstance, timelockInstance.unlock.afterDuration(), { blocks: 169 }); + + await expect(txPromise).rejects.toThrow(FailedRequireError); + await expect(txPromise).rejects.toThrow('Reason: Program called an OP_CHECKSEQUENCEVERIFY operation with an incompatible sequence type flag.'); + }); + + it('should succeed when a runtime this.age value is a valid relative timelock', async () => { + await expect(spend(timelockInstance, timelockInstance.unlock.afterPeriod(), { blocks: 10 })).resolves.toBeDefined(); + }); + + it('should fail when a runtime this.age value is not a valid relative timelock', async () => { + const txPromise = spend(invalidPeriodInstance, invalidPeriodInstance.unlock.afterPeriod(), { blocks: 65535 }); + + await expect(txPromise).rejects.toThrow(FailedRequireError); + await expect(txPromise).rejects.toThrow('with the following message: this.age value must be a number of blocks between 0 and 65535 (or a BIP68-encoded relative timelock).'); + await expect(txPromise).rejects.toThrow('Failing statement: require(this.age >= period)'); + }); + }); }); diff --git a/packages/cashscript/test/fixture/relative_timelock.artifact.ts b/packages/cashscript/test/fixture/relative_timelock.artifact.ts new file mode 100644 index 000000000..f467f5771 --- /dev/null +++ b/packages/cashscript/test/fixture/relative_timelock.artifact.ts @@ -0,0 +1,34 @@ +export default { + contractName: 'RelativeTimelock', + constructorInputs: [ + { name: 'period', type: 'int' }, + ], + abi: [ + { name: 'afterBlocks', inputs: [] }, + { name: 'afterDuration', inputs: [] }, + { name: 'afterPeriod', inputs: [] }, + ], + bytecode: 'OP_OVER OP_0 OP_NUMEQUAL OP_IF OP_10 OP_CHECKSEQUENCEVERIFY OP_2DROP OP_DROP OP_1 OP_ELSE OP_OVER OP_1 OP_NUMEQUAL OP_IF a90040 OP_CHECKSEQUENCEVERIFY OP_2DROP OP_DROP OP_1 OP_ELSE OP_SWAP OP_2 OP_NUMEQUALVERIFY OP_DUP OP_16 OP_RSHIFTNUM OP_DUP 40 OP_SUB OP_MUL OP_NOT OP_VERIFY OP_CHECKSEQUENCEVERIFY OP_DROP OP_1 OP_ENDIF OP_ENDIF', + source: 'contract RelativeTimelock(int period) {\n function afterBlocks() {\n require(this.age >= 10);\n }\n\n function afterDuration() {\n require(this.age >= 1 days);\n }\n\n function afterPeriod() {\n require(this.age >= period);\n }\n}\n', + fingerprint: '808c820f47d4ca3ba59fbd02d563d83088b40a888594bbf219078d4670a6c374', + debug: { + bytecode: '78009c635ab26d75516778519c6303a90040b26d7551677c529d76608e76014094959169b275516868', + sourceMap: '2:4:4:5;;;;3:28:3:30;:8::32:1;2:27:4:5;;;:4;6::8::0;;;;7:28:7:34;:8::36:1;6:29:8:5;;;:4;10::12::0;;;11:8:11:36:1;;;;;;;;;;;10:27:12:5;1:0:13:1;', + logs: [], + requires: [ + { ip: 6, line: 3 }, + { ip: 16, line: 7 }, + { ip: 32, line: 11, message: 'this.age value must be a number of blocks between 0 and 65535 (or a BIP68-encoded relative timelock)' }, + { ip: 33, line: 11 }, + ], + }, + compiler: { + name: 'cashc', + version: '0.14.0-next.6', + options: { + enforceFunctionParameterTypes: true, + enforceLocktimeGuard: true, + }, + }, + updatedAt: '2026-09-28T10:46:54.545Z', +} as const; diff --git a/packages/cashscript/test/fixture/relative_timelock.cash b/packages/cashscript/test/fixture/relative_timelock.cash new file mode 100644 index 000000000..6de9c67dd --- /dev/null +++ b/packages/cashscript/test/fixture/relative_timelock.cash @@ -0,0 +1,13 @@ +contract RelativeTimelock(int period) { + function afterBlocks() { + require(this.age >= 10); + } + + function afterDuration() { + require(this.age >= 1 days); + } + + function afterPeriod() { + require(this.age >= period); + } +} diff --git a/packages/cashscript/test/fixture/relative_timelock.json b/packages/cashscript/test/fixture/relative_timelock.json new file mode 100644 index 000000000..0e0d5d65d --- /dev/null +++ b/packages/cashscript/test/fixture/relative_timelock.json @@ -0,0 +1,34 @@ +{ + "contractName": "RelativeTimelock", + "constructorInputs": [ + { "name": "period", "type": "int" } + ], + "abi": [ + { "name": "afterBlocks", "inputs": [] }, + { "name": "afterDuration", "inputs": [] }, + { "name": "afterPeriod", "inputs": [] } + ], + "bytecode": "OP_OVER OP_0 OP_NUMEQUAL OP_IF OP_10 OP_CHECKSEQUENCEVERIFY OP_2DROP OP_DROP OP_1 OP_ELSE OP_OVER OP_1 OP_NUMEQUAL OP_IF a90040 OP_CHECKSEQUENCEVERIFY OP_2DROP OP_DROP OP_1 OP_ELSE OP_SWAP OP_2 OP_NUMEQUALVERIFY OP_DUP OP_16 OP_RSHIFTNUM OP_DUP 40 OP_SUB OP_MUL OP_NOT OP_VERIFY OP_CHECKSEQUENCEVERIFY OP_DROP OP_1 OP_ENDIF OP_ENDIF", + "source": "contract RelativeTimelock(int period) {\n function afterBlocks() {\n require(this.age >= 10);\n }\n\n function afterDuration() {\n require(this.age >= 1 days);\n }\n\n function afterPeriod() {\n require(this.age >= period);\n }\n}\n", + "fingerprint": "808c820f47d4ca3ba59fbd02d563d83088b40a888594bbf219078d4670a6c374", + "debug": { + "bytecode": "78009c635ab26d75516778519c6303a90040b26d7551677c529d76608e76014094959169b275516868", + "sourceMap": "2:4:4:5;;;;3:28:3:30;:8::32:1;2:27:4:5;;;:4;6::8::0;;;;7:28:7:34;:8::36:1;6:29:8:5;;;:4;10::12::0;;;11:8:11:36:1;;;;;;;;;;;10:27:12:5;1:0:13:1;", + "logs": [], + "requires": [ + { "ip": 6, "line": 3 }, + { "ip": 16, "line": 7 }, + { "ip": 32, "line": 11, "message": "this.age value must be a number of blocks between 0 and 65535 (or a BIP68-encoded relative timelock)" }, + { "ip": 33, "line": 11 } + ] + }, + "compiler": { + "name": "cashc", + "version": "0.14.0-next.6", + "options": { + "enforceFunctionParameterTypes": true, + "enforceLocktimeGuard": true + } + }, + "updatedAt": "2026-09-28T10:46:54.355Z" +} \ No newline at end of file diff --git a/packages/utils/src/bip68.ts b/packages/utils/src/bip68.ts index 1c31fc4b1..ff1ff8039 100644 --- a/packages/utils/src/bip68.ts +++ b/packages/utils/src/bip68.ts @@ -11,7 +11,7 @@ const SEQUENCE_LOCKTIME_TYPE_FLAG = (1 << 22); const BLOCKS_MAX = SEQUENCE_LOCKTIME_MASK; const SECONDS_MOD = 1 << SEQUENCE_LOCKTIME_GRANULARITY; -const SECONDS_MAX = SEQUENCE_LOCKTIME_MASK << SEQUENCE_LOCKTIME_GRANULARITY; +export const BIP68_SECONDS_MAX = SEQUENCE_LOCKTIME_MASK << SEQUENCE_LOCKTIME_GRANULARITY; interface DecodedSequence { blocks?: number; @@ -38,19 +38,21 @@ export function decodeBip68(sequence: number): DecodedSequence { export function encodeBip68({ blocks, seconds }: DecodedSequence): number { if (blocks !== undefined && seconds !== undefined) throw new TypeError('Cannot encode blocks AND seconds'); - // If the input is correct, we encode it as a sequence in seconds (using the SEQUENCE_LOCKTIME_TYPE_FLAG) + // If the input is correct, we encode it as a sequence in seconds (using the SEQUENCE_LOCKTIME_TYPE_FLAG). + // Seconds are rounded up to a multiple of 512, so the encoded timelock is never shorter than the requested one. if (seconds !== undefined) { - if (!Number.isFinite(seconds)) throw new TypeError('Expected Number seconds'); - if (seconds > SECONDS_MAX) throw new TypeError('Expected Number seconds <= ' + SECONDS_MAX); - if (seconds % SECONDS_MOD !== 0) throw new TypeError('Expected Number seconds as a multiple of ' + SECONDS_MOD); + if (!Number.isInteger(seconds) || seconds < 0 || seconds > BIP68_SECONDS_MAX) { + throw new TypeError(`Expected seconds to be an integer between 0 and ${BIP68_SECONDS_MAX}, but got ${seconds}`); + } - return SEQUENCE_LOCKTIME_TYPE_FLAG | (seconds >> SEQUENCE_LOCKTIME_GRANULARITY); + return SEQUENCE_LOCKTIME_TYPE_FLAG | Math.ceil(seconds / SECONDS_MOD); } // If the input is correct, we return the blocks (no further encoding needed) if (blocks !== undefined) { - if (!Number.isFinite(blocks)) throw new TypeError('Expected Number blocks'); - if (blocks > SEQUENCE_LOCKTIME_MASK) throw new TypeError('Expected Number blocks <= ' + BLOCKS_MAX); + if (!Number.isInteger(blocks) || blocks < 0 || blocks > BLOCKS_MAX) { + throw new TypeError(`Expected blocks to be an integer between 0 and ${BLOCKS_MAX}, but got ${blocks}`); + } return blocks; } @@ -58,3 +60,10 @@ export function encodeBip68({ blocks, seconds }: DecodedSequence): number { // If neither blocks nor seconds are provided, we assume the sequence is final return SEQUENCE_FINAL; } + +// Whether the value is an encoded BIP68 relative timelock (a number of blocks, or a number of 512-second units with the +// SEQUENCE_LOCKTIME_TYPE_FLAG), without the disable flag or any of the bits that BIP68 ignores +export function isBip68RelativeTimelock(value: number): boolean { + const usedBits = SEQUENCE_LOCKTIME_TYPE_FLAG | SEQUENCE_LOCKTIME_MASK; + return Number.isInteger(value) && value >= 0 && value <= usedBits && (value & ~usedBits) === 0; +} diff --git a/packages/utils/test/bip68.test.ts b/packages/utils/test/bip68.test.ts new file mode 100644 index 000000000..9683a6bde --- /dev/null +++ b/packages/utils/test/bip68.test.ts @@ -0,0 +1,57 @@ +import { decodeBip68, encodeBip68, isBip68RelativeTimelock } from '../src/index.js'; + +describe('BIP68', () => { + describe('encodeBip68()', () => { + it('should encode blocks as-is', () => { + expect(encodeBip68({ blocks: 0 })).toBe(0); + expect(encodeBip68({ blocks: 144 })).toBe(144); + expect(encodeBip68({ blocks: 65535 })).toBe(65535); + }); + + it('should encode seconds as 512-second units with the type flag, rounded up', () => { + expect(encodeBip68({ seconds: 0 })).toBe(0x400000); + expect(encodeBip68({ seconds: 512 })).toBe(0x400001); + expect(encodeBip68({ seconds: 513 })).toBe(0x400002); + expect(encodeBip68({ seconds: 86400 })).toBe(0x4000a9); + expect(encodeBip68({ seconds: 65535 * 512 })).toBe(0x40ffff); + }); + + it('should encode neither blocks nor seconds as a final sequence number', () => { + expect(encodeBip68({})).toBe(0xffffffff); + }); + + it('should reject invalid values', () => { + expect(() => encodeBip68({ blocks: 65536 })).toThrow('Expected blocks to be an integer between 0 and 65535'); + expect(() => encodeBip68({ blocks: -1 })).toThrow('Expected blocks to be an integer between 0 and 65535'); + expect(() => encodeBip68({ blocks: 1.5 })).toThrow('Expected blocks to be an integer between 0 and 65535'); + expect(() => encodeBip68({ seconds: 65535 * 512 + 1 })).toThrow('Expected seconds to be an integer between 0 and 33553920'); + expect(() => encodeBip68({ seconds: -1 })).toThrow('Expected seconds to be an integer between 0 and 33553920'); + expect(() => encodeBip68({ blocks: 1, seconds: 1 })).toThrow('Cannot encode blocks AND seconds'); + }); + + it('should round-trip through decodeBip68()', () => { + expect(decodeBip68(encodeBip68({ blocks: 144 }))).toEqual({ blocks: 144 }); + expect(decodeBip68(encodeBip68({ seconds: 86528 }))).toEqual({ seconds: 86528 }); + }); + }); + + describe('isBip68RelativeTimelock()', () => { + it('should accept encoded blocks and 512-second units', () => { + expect(isBip68RelativeTimelock(0)).toBe(true); + expect(isBip68RelativeTimelock(65535)).toBe(true); + expect(isBip68RelativeTimelock(0x400000)).toBe(true); + expect(isBip68RelativeTimelock(0x40ffff)).toBe(true); + }); + + it('should reject values with ignored bits, the disable flag or outside the valid range', () => { + expect(isBip68RelativeTimelock(65536)).toBe(false); + expect(isBip68RelativeTimelock(0x800000)).toBe(false); + expect(isBip68RelativeTimelock(0x410000)).toBe(false); + expect(isBip68RelativeTimelock(0x80000000)).toBe(false); + expect(isBip68RelativeTimelock(0xfffffffe)).toBe(false); + expect(isBip68RelativeTimelock(2 ** 32 + 1)).toBe(false); + expect(isBip68RelativeTimelock(-1)).toBe(false); + expect(isBip68RelativeTimelock(1.5)).toBe(false); + }); + }); +}); diff --git a/website/docs/compiler/compiler.md b/website/docs/compiler/compiler.md index 0c89d7b3e..9d1408325 100644 --- a/website/docs/compiler/compiler.md +++ b/website/docs/compiler/compiler.md @@ -166,6 +166,6 @@ This option is useful if you are certain that passing in incorrect function para #### enforceLocktimeGuard -The `enforceLocktimeGuard` option controls whether the compiler injects a `require(tx.time >= tx.locktime)` check when `tx.locktime` is used in a function without a `require(tx.time >= ...)` (or in some cases, `require(this.age >= ...)`) check already in scope. By default, it is set to `true`. +The `enforceLocktimeGuard` option controls whether the compiler injects a `require(tx.time >= tx.locktime)` check when `tx.locktime` is used in a function without a `require(tx.time >= ...)` or `require(this.age >= ...)` check already in scope. By default, it is set to `true`. If set to `false`, the compiler will not inject this guard. Without a guard, the value of `tx.locktime` is not guaranteed to be enforced by the network, which makes any comparison against `tx.locktime` meaningless and can bypass time-based restrictions in the contract. diff --git a/website/docs/language/contracts.md b/website/docs/language/contracts.md index 2d71308f9..32cf101d8 100644 --- a/website/docs/language/contracts.md +++ b/website/docs/language/contracts.md @@ -181,7 +181,7 @@ bytes32 constant EMPTY_HASH = 0x000000000000000000000000000000000000000000000000 contract Example() { function spend(int attempts) { require(attempts < MAX_ATTEMPTS); - require(tx.time >= EXTENDED_TIMEOUT); + require(this.age >= EXTENDED_TIMEOUT); } } ``` diff --git a/website/docs/language/globals.md b/website/docs/language/globals.md index 6449e6ef5..e9597d355 100644 --- a/website/docs/language/globals.md +++ b/website/docs/language/globals.md @@ -17,6 +17,8 @@ It can be difficult to fully grasp the intricacies of time locks, so if you're s require(tx.time >= ); ``` +Since `nLocktime` is a 32-bit number, the compiler rejects values outside the range `0` to `4,294,967,295`. It also rejects durations written with [time units](#time-units) that are below `500,000,000` (e.g. `2 hours`), because these would be treated as a block number rather than a time. + :::note To access the value of the `nLocktime` field for assignment, use the `tx.locktime` introspection variable. ::: @@ -24,19 +26,26 @@ To access the value of the `nLocktime` field for assignment, use the `tx.locktim Because of the way time locks work, **a corresponding time lock needs to be added to the transaction**. This can be done with the CashScript SDK by calling [`setLocktime()`][setLocktime()] on the `TransactionBuilder` instance. ### this.age -`this.age` is used to create a *relative* time lock on a UTXO. The value of `this.age` can either represent a number of blocks, or a number of *chunks*, which are 512 seconds. The corresponding *transaction level* time lock determines which of the two options is used. - -`this.age` corresponds to the `nSequence` field of the current evaluated *UTXO* using the `OP_CHECKSEQUENCEVERIFY` opcode. Because of this `this.age` can only be used in the following way: +`this.age` is used to create a *relative* time lock on a UTXO. `this.age` corresponds to the `nSequence` field of the current evaluated *UTXO* using the `OP_CHECKSEQUENCEVERIFY` opcode. Because of this `this.age` can only be used in the following way: ```solidity require(this.age >= ); ``` +The value of `this.age` can either represent a number of blocks, or an amount of time. A plain number is a number of blocks, up to `65,535` blocks. A duration written with [time units](#time-units) (e.g. `30 days`), or a global constant holding one, is encoded as a number of *chunks* of 512 seconds ([BIP68][bip68]). Durations are rounded up to a whole number of chunks, so the time lock is never shorter than the duration, and can be up to `33,553,920` seconds (about 388 days). + +```solidity +require(this.age >= 144); // 144 blocks +require(this.age >= 1 days); // 169 chunks of 512 seconds (86,528 seconds) +``` + +The compiler checks values that are known at compile time. Other values, such as a constructor parameter, are checked when the contract is spent: they must be a number of blocks up to `65,535`, or a number of chunks that is already encoded with the BIP68 type flag (`4,194,304 + chunks`). Since durations can only be encoded at compile time, time units cannot be used in a larger `this.age` expression. In a global constant, a sum, difference or remainder of durations is a duration, and so is a duration multiplied or divided by a plain number (e.g. `3 * 1 days` or `1 days / 2`). A ratio of two durations is a plain number (e.g. `1 days / 10 minutes` is `144` blocks). Other combinations, such as a duration plus a plain number (`1 days + 511`), have no clear unit and are rejected in `this.age`, and in `tx.time` when the result is below `500,000,000`. + :::note To access the value of the `nSequence` field for variable assignment, use the [`sequenceNumber` introspection variable](#txinputsisequencenumber). ::: -Because of the way time locks work, **a corresponding time lock needs to be added to the transaction**. This can be done in the CashScript SDK through [`addInput()`][addInput()] by passing the `sequence` as optional argument. However, the value passed into this function will always be treated as a number of blocks, so **it is currently not supported to use `this.age` as a number of second chunks**. +Because of the way time locks work, **a corresponding time lock needs to be added to the transaction**. This can be done in the CashScript SDK through [`addInput()`][addInput()] by passing the same number of `blocks` or `seconds` as the input's `sequence`, for example `{ sequence: { seconds: 86400 } }` for `require(this.age >= 1 days)`. ## Introspection variables Introspection functionality is used to create *covenant* contracts. Covenants are a technique used to put constraints on spending the money inside a smart contract. The main use case of this is limiting the addresses where money can be sent and the amount sent. To explore the possible uses of covenants inside smart contracts, read the [CashScript Covenants Guide][covenants-guide]. @@ -74,7 +83,7 @@ Represents the `nLocktime` field of the transaction. This is similar to the [`tx The use case for `tx.locktime` is to read the `nLocktime` value and add to the local state. Example usage for this is demonstrated in the [Sablier example](/docs/design/covenants#keeping-local-state-in-nfts). ::: -Using `tx.locktime` in a contract requires a non-final sequence number on the spending input. A `require(tx.time >= ...)` check enforces a non-final sequence number. As a safety measure, the CashScript compiler automatically injects a check for this when no such check is present. +Using `tx.locktime` in a contract requires a non-final sequence number on the spending input. A `require(tx.time >= ...)` or `require(this.age >= ...)` check enforces a non-final sequence number. As a safety measure, the CashScript compiler automatically injects a check for this when no such check is present. :::info The automatically injected check uses a synthetic `require(tx.time >= tx.locktime, "Using tx.locktime requires a non-final sequence number on the spending input.")`. This automatically enforces a non-final sequence number when `tx.locktime` is used. diff --git a/website/docs/releases/release-notes.md b/website/docs/releases/release-notes.md index cb7dd3c95..9f2d924e1 100644 --- a/website/docs/releases/release-notes.md +++ b/website/docs/releases/release-notes.md @@ -18,6 +18,9 @@ This release contains several breaking changes, please refer to the [migration n - :bug: Fix bug where date literal parsing was different per locale, it now uses UTC. - :bug: Fix bug where `LockingBytecodeNullData` used an incorrect push opcode for empty chunks and chunks of 128-255 bytes, so it did not match the SDK's `addOpReturnOutput()`. Chunks with a value or length that is known at compile time now use a precomputed push opcode, which makes them smaller. This changes the bytecode of all contracts that use `LockingBytecodeNullData`. - :bug: Fix bug where hex literals with an odd number of digits (e.g. `0x123`) compiled to a different value (`0x1203`), they now cause a compile error. +- :bug: Fix bug where `this.age` durations written with time units (e.g. `30 days`) were compared as a number of blocks. They are now encoded as BIP68 512-second chunks (rounded up), which changes the bytecode of contracts that use them. +- :bug: Fix bug where `this.age` values that are not a valid BIP68 relative timelock were silently truncated or disabled the timelock. Compile-time values now cause a compile error, and runtime values are checked when the contract is spent. +- :bug: Fix bug where `tx.time` values outside the 32-bit locktime range or written as a duration (e.g. `2 hours`) compiled but could never be satisfied as intended, they now cause a compile error. - :racehorse: Add new `OP_SWAP OP_MUL`, `OP_NOT OP_NOT` and `OP_TOALTSTACK OP_FROMALTSTACK` optimisations. - :racehorse: Add new optimisations for loop counter updates, reassignments inside loops and order-independent operations (`OP_BOOLAND`, `OP_BOOLOR`, `OP_MIN`, `OP_MAX`). - :racehorse: Greatly improve compiler speed for very large contracts. @@ -25,6 +28,7 @@ This release contains several breaking changes, please refer to the [migration n #### CashScript SDK - :sparkles: Add support for debugging user-defined functions, including stack traces for nested functions. - :sparkles: Add a `validateTransactions` option (default: `true`) to the `MockNetworkProvider` to validate sent transactions against the BCH VM. +- :sparkles: Add support for passing a relative timelock as `{ blocks }` or `{ seconds }` to the `sequence` input option, which is encoded as a BIP68 sequence number. - :hammer_and_wrench: MockNetworkProvider now warns instead of errors when resubmitting an already-seen transaction. - :hammer_and_wrench: Add `lockingBytecode` field to the `Utxo` interface, set automatically on all UTXOs returned by network providers. - :hammer_and_wrench: **BREAKING**: Replace the `SignatureTemplate`'s `getHashType()`, `getPublicKey()` and `getSignatureAlgorithm()` methods with the `sighashType`, `publicKey` and `signatureAlgorithm` properties. @@ -34,6 +38,8 @@ This release contains several breaking changes, please refer to the [migration n - :hammer_and_wrench: `ElectrumNetworkProvider` now negotiates Electrum protocol 1.5.0 (was 1.4.1) and uses `blockchain.headers.get_tip` in `getBlockHeight()`, so long-lived connections are no longer subscribed to new headers. Custom electrum clients should negotiate 1.5.0 or later. - :hammer_and_wrench: Update `@electrum-cash/network` to 4.4.0. In a browser, the `ElectrumNetworkProvider`'s connection is now also closed while the page is hidden (it was already closed while offline) and opened again afterwards, restoring any subscriptions (see [Browser visibility and connectivity](/docs/sdk/electrum-network-provider#browser-visibility-and-connectivity)). - :hammer_and_wrench: Make `cashscript/vitest` types compatible with Vitest 5. +- :hammer_and_wrench: Reject `sequence` input options that enable a relative timelock with bits that BIP68 ignores. +- :hammer_and_wrench: Show the underlying reason when a require statement fails for another reason than a false condition, such as a `this.age` check with a missing sequence number. - :boom: **BREAKING**: Remove `generateLockingBytecode()` from the `Unlocker` interface. - :bug: Fix bug where invalid hex strings were silently encoded to different bytes. - :bug: Fix bug where `pubkey` arguments were not checked to be 33 or 65 bytes. diff --git a/website/docs/sdk/transaction-builder.md b/website/docs/sdk/transaction-builder.md index 525042d34..e8372d3f7 100644 --- a/website/docs/sdk/transaction-builder.md +++ b/website/docs/sdk/transaction-builder.md @@ -59,6 +59,14 @@ transactionBuilder.addInput(utxo: SpendableUtxo, unlocker: Unlocker, options?: I Adds a single input UTXO to the transaction that can be unlocked using the provided unlocker. The unlocker can be derived from a `SignatureTemplate` or a `Contract` instance's spending functions. The `InputOptions` object can be used to specify the sequence number of the input. The default sequence number is `0xfffffffe` (non-final sequence number). +```ts +interface InputOptions { + sequence?: number | { blocks: number } | { seconds: number }; +} +``` + +To satisfy a [`require(this.age >= ...)`](/docs/language/globals#thisage) check, pass the same number of `blocks` or `seconds` as the `sequence`, which is then encoded as a [BIP68](https://github.com/bitcoin/bips/blob/master/bip-0068.mediawiki) relative time lock (rounding seconds up to a multiple of 512, just like the compiler). A raw sequence number can also be passed, but a sequence number that enables a relative time lock may not set any of the bits that BIP68 ignores. + :::note It is possible to create custom unlockers by implementing the `Unlocker` interface. Most use cases however are covered by the `SignatureTemplate` and `Contract` classes. ::: @@ -72,6 +80,9 @@ const aliceUtxos = await provider.getUtxos(aliceAddress); transactionBuilder.addInput(contractUtxos[0], contract.unlock.spend()); transactionBuilder.addInput(aliceUtxos[0], aliceTemplate.unlockP2PKH()); + +// Add an input to a contract function that uses require(this.age >= 1 days) +transactionBuilder.addInput(contractUtxos[1], contract.unlock.claim(), { sequence: { seconds: 86400 } }); ``` ### addInputs()