Skip to content
Open
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
9 changes: 5 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,14 +113,15 @@ jobs:
#
# The floor is 1.84, the first release with the `wasm32v1-none` target
# that `stellar contract build` requires. Staying below ~1.97 keeps the
# `TryFromIntError` layout ethnum expects.
# `TryFromIntError` layout ethnum expects. 1.89.0 also satisfies the
# darling 0.24 / serde_with 3.24 floor of 1.88.
#
# To move off this pin: upgrade stellar-cli past 22.8.1 to a release whose
# lockfile uses ethnum >= 1.5.3, then return to `@stable`.
- name: Setup Rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.86.0
toolchain: 1.89.0
targets: wasm32-unknown-unknown,wasm32v1-none

# Install the released binary instead of `cargo install`: stellar-cli
Expand Down Expand Up @@ -152,7 +153,7 @@ jobs:
continue-on-error: true

- name: Pin ed25519-dalek to 2.x
run: cargo update -p ed25519-dalek@3.0.0 --precise 2.2.0
run: cargo update -p ed25519-dalek@3.0.0 --precise 2.2.0 || echo "ed25519-dalek 3.0.0 not in graph, skipping pin"

- name: Build WASM
run: stellar contract build
Expand All @@ -167,7 +168,7 @@ jobs:
- name: Pin ed25519-dalek to the 2.x line
run: |
cargo generate-lockfile
cargo update -p ed25519-dalek@3.0.0 --precise 2.2.0
cargo update -p ed25519-dalek@3.0.0 --precise 2.2.0 || echo "ed25519-dalek 3.0.0 not in graph, skipping pin"

- name: Run unit tests
run: cargo test --release
Expand Down
2 changes: 0 additions & 2 deletions contracts/contracts/HTLCBridge.sol
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "./interfaces/IHTLCEscrowV2.sol";

contract HTLCBridge {
address public immutable escrowFactory;
address public activeV2Escrow;
Expand Down
17 changes: 13 additions & 4 deletions contracts/contracts/v2/HTLCEscrow.sol
Original file line number Diff line number Diff line change
Expand Up @@ -194,10 +194,19 @@ contract HTLCEscrow is IHTLCEscrow, ReentrancyGuard {
if (!resolverRegistry.isActive(msg.sender)) revert ClaimResolverNotRegistered();
}

// Verify hashlock. We accept both sha256 and keccak256 digests
// so that a Soroban-side counterpart (sha256) and a classic EVM
// counterparty (keccak256) can share the same on-chain hashlock.
bytes32 sha = sha256(preimage);
// An empty preimage is refused explicitly. Without this, sha256(orderId
// ‖ "") is a well-defined digest, so a caller who opened an order
// against it could claim with empty bytes.
if (preimage.length == 0) revert InvalidPreimage();

// Verify the order-bound hashlock v1: sha256(orderId || preimage), the
// same construction the Soroban contract verifies and the SDK computes
// in `hashOrderPreimage`. Binding the order id is what stops a preimage
// revealed for one order from being replayed against another.
//
// `kek` is recorded for callers that prefer keccak256 addressing; it is
// provenance only and is never used to authorise the claim.
bytes32 sha = sha256(abi.encodePacked(orderId, preimage));
bytes32 kek = keccak256(preimage);
if (sha != order.hashlock) revert InvalidPreimage();

Expand Down
1 change: 1 addition & 0 deletions contracts/forge-cache/solidity-files-cache.json

Large diffs are not rendered by default.

61 changes: 44 additions & 17 deletions contracts/test/foundry/HTLCBridge.legacy.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,37 +4,64 @@ pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {HTLCBridge} from "../../contracts/HTLCBridge.sol";

/**
* The legacy bridge refuses to open a new lock once a v2 escrow is active:
* funds must go through HTLCEscrow instead, so the old path cannot be used to
* strand an order that the v2 coordinator will never see.
*/
contract HTLCBridgeLegacyLockTest is Test {
HTLCBridge bridge;

function setUp() public {
bridge = new HTLCBridge();
bridge = new HTLCBridge(address(0));
vm.deal(address(this), 1 ether);
}

function _lock() internal returns (uint256) {
return bridge.createOrder{value: 0.01 ether}(
address(0),
1,
function _lock(address target) internal {
bridge.newLock{value: 0.01 ether}(
bytes32(uint256(1)),
block.timestamp + 2 hours,
0,
address(this),
address(this),
1,
bytes32(0),
false
target,
0.01 ether,
block.timestamp + 2 hours
);
}

function test_legacyLockRevertsWhenV2EscrowIsActive() public {
bridge.setActiveV2Escrow(address(0xBEEF));
vm.expectRevert(bytes("legacy lock refused"));
_lock();
address v2 = address(0xBEEF);
bridge.setActiveV2Escrow(v2);

vm.expectRevert(bytes("Legacy lock rejected: v2 escrow active"));
_lock(address(0xCAFE));
}

function test_legacyLockSucceedsWhenV2IsUnset() public {
uint256 id = _lock();
assertEq(id, 1);
_lock(address(0xCAFE));
assertTrue(bridge.locked(bytes32(uint256(1))));
}

function test_legacyLockToTheActiveV2EscrowIsAllowed() public {
address v2 = address(0xBEEF);
bridge.setActiveV2Escrow(v2);

// The gate refuses a legacy target, not a lock that names the v2 escrow.
_lock(v2);
assertTrue(bridge.locked(bytes32(uint256(1))));
}

function test_legacyLockRejectsADuplicateHashlock() public {
_lock(address(0xCAFE));

vm.expectRevert(bytes("Already locked"));
_lock(address(0xCAFE));
}

function test_legacyLockRejectsAMismatchedValue() public {
vm.expectRevert(bytes("Amount mismatch"));
bridge.newLock(
bytes32(uint256(2)),
address(0xCAFE),
0.01 ether,
block.timestamp + 2 hours
);
}
}
29 changes: 18 additions & 11 deletions contracts/test/v2/HTLCEscrow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,9 @@ describe("HTLCEscrow v2", () => {
});

describe("claimOrder", () => {
it("reverts when the escrow is not bound to a resolver registry", async () => {
it("stays permissionless when the escrow is not bound to a resolver registry", async () => {
const [sender, beneficiary] = await ethers.getSigners();
// No registry bound: neither createOrder nor claimOrder is gated.
const escrow = await deployEscrow();
const preimage = randomBytes32();
const hashlock = orderHashlock(1n, preimage);
Expand All @@ -239,21 +240,22 @@ describe("HTLCEscrow v2", () => {

await expect(
escrow.connect(sender).claimOrder(1, preimage)
).to.be.revertedWithCustomError(escrow, "ResolverNotAuthorised");
).to.not.be.reverted;
});

it("claims successfully for a registered resolver on the bound registry pair", async () => {
const [owner, sender, beneficiary] = await ethers.getSigners();
const { token, registry } = await deployRegistry();
const stake = ethers.parseEther("1");
// The registry requires at least MIN_STAKE.
const stake = MIN_STAKE;

await token.transfer(sender.address, stake);
await token.connect(sender).approve(await registry.getAddress(), stake);
await registerResolver(registry, sender, stake);

const escrow = await deployEscrow(await registry.getAddress());
const preimage = randomBytes32();
const hashlock = ethers.sha256(preimage);
const hashlock = orderHashlock(1n, preimage);

await escrow.connect(sender).createOrder(
beneficiary.address,
Expand Down Expand Up @@ -290,7 +292,7 @@ describe("HTLCEscrow v2", () => {
await registerResolver(registry, relayer, ethers.parseEther("1"));

const preimage = randomBytes32();
const hashlock = ethers.sha256(preimage);
const hashlock = orderHashlock(1n, preimage);

await escrow.connect(sender).createOrder(
beneficiary.address,
Expand Down Expand Up @@ -622,7 +624,7 @@ describe("HTLCEscrow v2", () => {
const [sender, beneficiary, cleaner] = await ethers.getSigners();
const escrow = await deployEscrow();
const preimage = randomBytes32();
const hashlock = ethers.sha256(preimage);
const hashlock = orderHashlock(1n, preimage);

await escrow.connect(sender).createOrder(
beneficiary.address,
Expand Down Expand Up @@ -694,13 +696,13 @@ describe("HTLCEscrow v2", () => {
).to.be.revertedWithCustomError(escrow, "ResolverNotAuthorised");
});

it("lets an active resolver create and a stranger claim permissionlessly", async () => {
it("lets an active resolver create and claim, but refuses a stranger claim", async () => {
const [, beneficiary, , resolver, stranger] = await ethers.getSigners();
const { token, registry, escrow } = await deployGatedEscrow();
await registerResolver(token, registry, resolver);

const preimage = randomBytes32();
const hashlock = ethers.sha256(preimage);
const hashlock = orderHashlock(1n, preimage);

await escrow.connect(resolver).createOrder(
beneficiary.address,
Expand All @@ -713,9 +715,14 @@ describe("HTLCEscrow v2", () => {
{ value: AMOUNT + SAFETY_DEPOSIT }
);

// `stranger` is not a registered resolver but claim is permissionless.
// A bound registry gates claims too (#257): removing a resolver stops them
// from claiming even an order they opened while active.
await expect(
escrow.connect(stranger).claimOrder(1, preimage)
).to.be.revertedWithCustomError(escrow, "ClaimResolverNotRegistered");

const before = await ethers.provider.getBalance(beneficiary.address);
await escrow.connect(stranger).claimOrder(1, preimage);
await escrow.connect(resolver).claimOrder(1, preimage);
expect(await ethers.provider.getBalance(beneficiary.address)).to.equal(before + AMOUNT);
expect((await escrow.getOrder(1)).status).to.equal(1); // Claimed
});
Expand Down Expand Up @@ -812,7 +819,7 @@ describe("HTLCEscrow v2", () => {
/** Create a standard native-ETH order; resolver must be the sender (registry-gated). */
async function createOrder(escrow: HTLCEscrow, resolver: any, beneficiary: any) {
const preimage = randomBytes32();
const hashlock = ethers.sha256(preimage);
const hashlock = orderHashlock(1n, preimage);
await escrow.connect(resolver).createOrder(
beneficiary.address,
resolver.address,
Expand Down
6 changes: 2 additions & 4 deletions coordinator/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { QuoteService } from "./services/quote-service.js";
import { SecretService } from "./services/secret-service.js";
import { createApp } from "./server/app.js";
import { EthereumListener } from "./listeners/ethereum-listener.js";
import { ChainEventProcessor } from "./services/chain-events.js";
import { SorobanListener } from "./listeners/soroban-listener.js";

async function main(): Promise<void> {
Expand Down Expand Up @@ -43,9 +42,8 @@ async function main(): Promise<void> {
log.info({ port: cfg.port }, "HTTP server listening");
});

const chainEvents = new ChainEventProcessor(repo, orders, secrets, log);
const ethListener = new EthereumListener(cfg, orders, log, chainEvents);
const sorobanListener = new SorobanListener(cfg, orders, log, chainEvents);
const ethListener = new EthereumListener(cfg, orders, log);
const sorobanListener = new SorobanListener(cfg, orders, log);
// A cursor saved for another network aborts startup (see main().catch).
await ethListener.start();
await sorobanListener.start();
Expand Down
88 changes: 0 additions & 88 deletions coordinator/src/listeners/ethereum-listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,6 @@ export class EthereumListener {
const address = this.cfg.ethereum.htlcEscrow;
this.log.info({ contract: address }, "starting");

if (this.events) {
const saved = await this.events.resume("ethereum", this.networkId);
const head = await this.client.getBlockNumber();
if (saved) {
await this.catchUp(address, BigInt(saved.position), head);
} else {
await this.events.advance("ethereum", this.networkId, Number(head));
}
}

this.unwatchers.push(
this.client.watchEvent({
address,
Expand Down Expand Up @@ -139,86 +129,8 @@ export class EthereumListener {
);
}

private async handleCreated(log: {
args: { hashlock?: `0x${string}`; orderId?: bigint; timelock?: bigint };
transactionHash: string;
blockNumber: bigint | null;
}): Promise<void> {
const hashlock = log.args.hashlock!;
try {
const order = await this.orders.findByHashlock(hashlock);
if (!order) {
this.log.info(
{ hashlock, orderId: log.args.orderId?.toString() },
"ETH order observed without local announce"
);
return;
}
await this.orders.recordSrcLock({
publicId: order.publicId,
orderId: log.args.orderId!.toString(),
txHash: log.transactionHash,
blockNumber: Number(log.blockNumber),
timelock: Number(log.args.timelock!)
});
} catch (err) {
this.log.warn({ err, hashlock }, "could not record src lock");
}
}

/** Live path: apply one settlement event and move the cursor to its block. */
private async applySettlement(ev: SettlementEvent): Promise<void> {
if (!this.events) return;
try {
await this.events.processBatch(this.networkId, "ethereum", [ev], ev.position);
} catch (err) {
// The cursor stays behind this event, so it is redelivered on restart.
this.log.error({ err, txHash: ev.txHash }, "could not apply settlement event");
}
}

private async catchUp(address: `0x${string}`, from: bigint, to: bigint): Promise<void> {
if (!this.events || from > to) return;
this.log.info({ from: from.toString(), to: to.toString() }, "resuming from saved cursor");
const [created, claimed, refunded] = await Promise.all([
this.client.getLogs({ address, event: ORDER_CREATED, fromBlock: from, toBlock: to }),
this.client.getLogs({ address, event: ORDER_CLAIMED, fromBlock: from, toBlock: to }),
this.client.getLogs({ address, event: ORDER_REFUNDED, fromBlock: from, toBlock: to })
]);
for (const log of created) await this.handleCreated(log);
await this.events.processBatch(
this.networkId,
"ethereum",
[
...claimed.map((l) => toSettlement(l, "claimed")),
...refunded.map((l) => toSettlement(l, "refunded"))
],
Number(to)
);
}

stop(): void {
for (const u of this.unwatchers) u();
this.unwatchers = [];
}
}

function toSettlement(
log: {
args: { orderId?: bigint; preimage?: `0x${string}` };
transactionHash: string;
logIndex: number | null;
blockNumber: bigint | null;
},
kind: "claimed" | "refunded"
): SettlementEvent {
return {
chain: "ethereum",
kind,
onchainOrderId: log.args.orderId!.toString(),
txHash: log.transactionHash,
logIndex: log.logIndex ?? 0,
position: Number(log.blockNumber),
preimage: kind === "claimed" ? log.args.preimage : undefined
};
}
Loading
Loading