diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 637d3d71..b720c715 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 @@ -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 diff --git a/contracts/contracts/HTLCBridge.sol b/contracts/contracts/HTLCBridge.sol index 7b5333fa..43295e55 100644 --- a/contracts/contracts/HTLCBridge.sol +++ b/contracts/contracts/HTLCBridge.sol @@ -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; diff --git a/contracts/contracts/v2/HTLCEscrow.sol b/contracts/contracts/v2/HTLCEscrow.sol index 18bcdb04..7295f8be 100644 --- a/contracts/contracts/v2/HTLCEscrow.sol +++ b/contracts/contracts/v2/HTLCEscrow.sol @@ -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(); diff --git a/contracts/forge-cache/solidity-files-cache.json b/contracts/forge-cache/solidity-files-cache.json new file mode 100644 index 00000000..a97a4642 --- /dev/null +++ b/contracts/forge-cache/solidity-files-cache.json @@ -0,0 +1 @@ +{"_format":"","paths":{"artifacts":"out","build_infos":"out/build-info","sources":"contracts","tests":"test/foundry","scripts":"script","libraries":["lib","node_modules"]},"files":{"contracts/EscrowFactory.sol":{"lastModificationDate":1791190605940,"contentHash":"76346cfce4f8b67c","interfaceReprHash":null,"sourceName":"contracts/EscrowFactory.sol","imports":["contracts/HTLCBridge.sol","node_modules/@openzeppelin/contracts/access/Ownable.sol","node_modules/@openzeppelin/contracts/token/ERC20/IERC20.sol","node_modules/@openzeppelin/contracts/utils/Context.sol","node_modules/@openzeppelin/contracts/utils/Create2.sol","node_modules/@openzeppelin/contracts/utils/Errors.sol","node_modules/@openzeppelin/contracts/utils/LowLevelCall.sol","node_modules/@openzeppelin/contracts/utils/ReentrancyGuard.sol","node_modules/@openzeppelin/contracts/utils/StorageSlot.sol"],"versionRequirement":"^0.8.24","artifacts":{"EscrowFactory":{"0.8.24":{"default":{"path":"EscrowFactory.sol/EscrowFactory.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"contracts/HTLCBridge.sol":{"lastModificationDate":1791195842773,"contentHash":"0041cd6c772537d1","interfaceReprHash":null,"sourceName":"contracts/HTLCBridge.sol","imports":[],"versionRequirement":"^0.8.0","artifacts":{"HTLCBridge":{"0.8.24":{"default":{"path":"HTLCBridge.sol/HTLCBridge.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"contracts/MainnetHTLC.sol":{"lastModificationDate":1791190605940,"contentHash":"32b63f74caff3d39","interfaceReprHash":null,"sourceName":"contracts/MainnetHTLC.sol","imports":["contracts/v2/interfaces/IResolverRegistry.sol","node_modules/@openzeppelin/contracts/token/ERC20/IERC20.sol","node_modules/@openzeppelin/contracts/utils/ReentrancyGuard.sol","node_modules/@openzeppelin/contracts/utils/StorageSlot.sol"],"versionRequirement":"^0.8.24","artifacts":{"MainnetHTLC":{"0.8.24":{"default":{"path":"MainnetHTLC.sol/MainnetHTLC.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"contracts/TestERC20.sol":{"lastModificationDate":1791190605940,"contentHash":"9fb9ac09d91bbe73","interfaceReprHash":null,"sourceName":"contracts/TestERC20.sol","imports":["node_modules/@openzeppelin/contracts/interfaces/draft-IERC6093.sol","node_modules/@openzeppelin/contracts/token/ERC20/ERC20.sol","node_modules/@openzeppelin/contracts/token/ERC20/IERC20.sol","node_modules/@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol","node_modules/@openzeppelin/contracts/utils/Context.sol"],"versionRequirement":"^0.8.24","artifacts":{"TestERC20":{"0.8.24":{"default":{"path":"TestERC20.sol/TestERC20.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"contracts/v2/HTLCEscrow.sol":{"lastModificationDate":1791201124707,"contentHash":"1fa948b278bd762e","interfaceReprHash":null,"sourceName":"contracts/v2/HTLCEscrow.sol","imports":["contracts/v2/interfaces/IHTLCEscrow.sol","contracts/v2/interfaces/IResolverRegistry.sol","node_modules/@openzeppelin/contracts/interfaces/IERC1363.sol","node_modules/@openzeppelin/contracts/interfaces/IERC165.sol","node_modules/@openzeppelin/contracts/interfaces/IERC20.sol","node_modules/@openzeppelin/contracts/token/ERC20/IERC20.sol","node_modules/@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol","node_modules/@openzeppelin/contracts/utils/Address.sol","node_modules/@openzeppelin/contracts/utils/Errors.sol","node_modules/@openzeppelin/contracts/utils/LowLevelCall.sol","node_modules/@openzeppelin/contracts/utils/ReentrancyGuard.sol","node_modules/@openzeppelin/contracts/utils/StorageSlot.sol","node_modules/@openzeppelin/contracts/utils/introspection/IERC165.sol"],"versionRequirement":"^0.8.24","artifacts":{"HTLCEscrow":{"0.8.24":{"default":{"path":"HTLCEscrow.sol/HTLCEscrow.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"contracts/v2/ResolverRegistry.sol":{"lastModificationDate":1791190605940,"contentHash":"c738b7a9996b43f6","interfaceReprHash":null,"sourceName":"contracts/v2/ResolverRegistry.sol","imports":["contracts/v2/interfaces/IResolverRegistry.sol","node_modules/@openzeppelin/contracts/access/Ownable.sol","node_modules/@openzeppelin/contracts/access/Ownable2Step.sol","node_modules/@openzeppelin/contracts/interfaces/IERC1363.sol","node_modules/@openzeppelin/contracts/interfaces/IERC165.sol","node_modules/@openzeppelin/contracts/interfaces/IERC20.sol","node_modules/@openzeppelin/contracts/token/ERC20/IERC20.sol","node_modules/@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol","node_modules/@openzeppelin/contracts/utils/Context.sol","node_modules/@openzeppelin/contracts/utils/ReentrancyGuard.sol","node_modules/@openzeppelin/contracts/utils/StorageSlot.sol","node_modules/@openzeppelin/contracts/utils/introspection/IERC165.sol"],"versionRequirement":"^0.8.24","artifacts":{"ResolverRegistry":{"0.8.24":{"default":{"path":"ResolverRegistry.sol/ResolverRegistry.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"contracts/v2/interfaces/IHTLCEscrow.sol":{"lastModificationDate":1791190605940,"contentHash":"5a9c2f8dfc44f578","interfaceReprHash":null,"sourceName":"contracts/v2/interfaces/IHTLCEscrow.sol","imports":[],"versionRequirement":"^0.8.24","artifacts":{"IHTLCEscrow":{"0.8.24":{"default":{"path":"IHTLCEscrow.sol/IHTLCEscrow.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"contracts/v2/interfaces/IResolverRegistry.sol":{"lastModificationDate":1791190605940,"contentHash":"f2716d11f3463b23","interfaceReprHash":null,"sourceName":"contracts/v2/interfaces/IResolverRegistry.sol","imports":[],"versionRequirement":"^0.8.24","artifacts":{"IResolverRegistry":{"0.8.24":{"default":{"path":"IResolverRegistry.sol/IResolverRegistry.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"lib/forge-std/src/Base.sol":{"lastModificationDate":1791190605941,"contentHash":"057da15af4544b7a","interfaceReprHash":null,"sourceName":"lib/forge-std/src/Base.sol","imports":["lib/forge-std/src/StdStorage.sol","lib/forge-std/src/Vm.sol"],"versionRequirement":">=0.8.13, <0.9.0","artifacts":{"CommonBase":{"0.8.24":{"default":{"path":"Base.sol/CommonBase.json","build_id":"93c75e430ce3ec09"}}},"ScriptBase":{"0.8.24":{"default":{"path":"Base.sol/ScriptBase.json","build_id":"93c75e430ce3ec09"}}},"TestBase":{"0.8.24":{"default":{"path":"Base.sol/TestBase.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"lib/forge-std/src/StdAssertions.sol":{"lastModificationDate":1791190605941,"contentHash":"fd31761b7a5b1a8b","interfaceReprHash":null,"sourceName":"lib/forge-std/src/StdAssertions.sol","imports":["lib/forge-std/src/Vm.sol"],"versionRequirement":">=0.8.13, <0.9.0","artifacts":{"StdAssertions":{"0.8.24":{"default":{"path":"StdAssertions.sol/StdAssertions.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"lib/forge-std/src/StdChains.sol":{"lastModificationDate":1791190605941,"contentHash":"0c1989d5064f8ddf","interfaceReprHash":null,"sourceName":"lib/forge-std/src/StdChains.sol","imports":["lib/forge-std/src/Vm.sol"],"versionRequirement":">=0.8.13, <0.9.0","artifacts":{"StdChains":{"0.8.24":{"default":{"path":"StdChains.sol/StdChains.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"lib/forge-std/src/StdCheats.sol":{"lastModificationDate":1791190605942,"contentHash":"c1a8a8aaa0d07a9b","interfaceReprHash":null,"sourceName":"lib/forge-std/src/StdCheats.sol","imports":["lib/forge-std/src/StdStorage.sol","lib/forge-std/src/Vm.sol","lib/forge-std/src/console.sol","lib/forge-std/src/console2.sol"],"versionRequirement":">=0.8.13, <0.9.0","artifacts":{"StdCheats":{"0.8.24":{"default":{"path":"StdCheats.sol/StdCheats.json","build_id":"93c75e430ce3ec09"}}},"StdCheatsSafe":{"0.8.24":{"default":{"path":"StdCheats.sol/StdCheatsSafe.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"lib/forge-std/src/StdConstants.sol":{"lastModificationDate":1791190605942,"contentHash":"6e64e3e0f1e270b1","interfaceReprHash":null,"sourceName":"lib/forge-std/src/StdConstants.sol","imports":["lib/forge-std/src/Vm.sol","lib/forge-std/src/interfaces/IMulticall3.sol"],"versionRequirement":">=0.8.13, <0.9.0","artifacts":{"StdConstants":{"0.8.24":{"default":{"path":"StdConstants.sol/StdConstants.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"lib/forge-std/src/StdError.sol":{"lastModificationDate":1791190605942,"contentHash":"1c856665ab3f24ad","interfaceReprHash":null,"sourceName":"lib/forge-std/src/StdError.sol","imports":[],"versionRequirement":">=0.8.13, <0.9.0","artifacts":{"stdError":{"0.8.24":{"default":{"path":"StdError.sol/stdError.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"lib/forge-std/src/StdInvariant.sol":{"lastModificationDate":1791190605942,"contentHash":"b482f161ff5afe1f","interfaceReprHash":null,"sourceName":"lib/forge-std/src/StdInvariant.sol","imports":[],"versionRequirement":">=0.8.13, <0.9.0","artifacts":{"StdInvariant":{"0.8.24":{"default":{"path":"StdInvariant.sol/StdInvariant.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"lib/forge-std/src/StdJson.sol":{"lastModificationDate":1791190605942,"contentHash":"f5a3ad0946d95283","interfaceReprHash":null,"sourceName":"lib/forge-std/src/StdJson.sol","imports":["lib/forge-std/src/Vm.sol"],"versionRequirement":">=0.8.13, <0.9.0","artifacts":{"stdJson":{"0.8.24":{"default":{"path":"StdJson.sol/stdJson.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"lib/forge-std/src/StdMath.sol":{"lastModificationDate":1791190605942,"contentHash":"a1c513020c710db6","interfaceReprHash":null,"sourceName":"lib/forge-std/src/StdMath.sol","imports":[],"versionRequirement":">=0.8.13, <0.9.0","artifacts":{"stdMath":{"0.8.24":{"default":{"path":"StdMath.sol/stdMath.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"lib/forge-std/src/StdStorage.sol":{"lastModificationDate":1791190605942,"contentHash":"847d6efe5e5f0088","interfaceReprHash":null,"sourceName":"lib/forge-std/src/StdStorage.sol","imports":["lib/forge-std/src/Vm.sol"],"versionRequirement":">=0.8.13, <0.9.0","artifacts":{"stdStorage":{"0.8.24":{"default":{"path":"StdStorage.sol/stdStorage.json","build_id":"93c75e430ce3ec09"}}},"stdStorageSafe":{"0.8.24":{"default":{"path":"StdStorage.sol/stdStorageSafe.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"lib/forge-std/src/StdStyle.sol":{"lastModificationDate":1791190605942,"contentHash":"c527571c73ed6f30","interfaceReprHash":null,"sourceName":"lib/forge-std/src/StdStyle.sol","imports":["lib/forge-std/src/Vm.sol"],"versionRequirement":">=0.8.13, <0.9.0","artifacts":{"StdStyle":{"0.8.24":{"default":{"path":"StdStyle.sol/StdStyle.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"lib/forge-std/src/StdToml.sol":{"lastModificationDate":1791190605942,"contentHash":"e53a8ef003bfe8a2","interfaceReprHash":null,"sourceName":"lib/forge-std/src/StdToml.sol","imports":["lib/forge-std/src/Vm.sol"],"versionRequirement":">=0.8.13, <0.9.0","artifacts":{"stdToml":{"0.8.24":{"default":{"path":"StdToml.sol/stdToml.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"lib/forge-std/src/StdUtils.sol":{"lastModificationDate":1791190605942,"contentHash":"2b3875c15dc0cae3","interfaceReprHash":null,"sourceName":"lib/forge-std/src/StdUtils.sol","imports":["lib/forge-std/src/StdConstants.sol","lib/forge-std/src/Vm.sol","lib/forge-std/src/interfaces/IMulticall3.sol"],"versionRequirement":">=0.8.13, <0.9.0","artifacts":{"StdUtils":{"0.8.24":{"default":{"path":"StdUtils.sol/StdUtils.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"lib/forge-std/src/Test.sol":{"lastModificationDate":1791190605942,"contentHash":"bd0e131f71fa5665","interfaceReprHash":null,"sourceName":"lib/forge-std/src/Test.sol","imports":["lib/forge-std/src/Base.sol","lib/forge-std/src/StdAssertions.sol","lib/forge-std/src/StdChains.sol","lib/forge-std/src/StdCheats.sol","lib/forge-std/src/StdConstants.sol","lib/forge-std/src/StdError.sol","lib/forge-std/src/StdInvariant.sol","lib/forge-std/src/StdJson.sol","lib/forge-std/src/StdMath.sol","lib/forge-std/src/StdStorage.sol","lib/forge-std/src/StdStyle.sol","lib/forge-std/src/StdToml.sol","lib/forge-std/src/StdUtils.sol","lib/forge-std/src/Vm.sol","lib/forge-std/src/console.sol","lib/forge-std/src/console2.sol","lib/forge-std/src/interfaces/IMulticall3.sol","lib/forge-std/src/safeconsole.sol"],"versionRequirement":">=0.8.13, <0.9.0","artifacts":{"Test":{"0.8.24":{"default":{"path":"Test.sol/Test.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"lib/forge-std/src/Vm.sol":{"lastModificationDate":1791190605943,"contentHash":"9843952acc0887b4","interfaceReprHash":null,"sourceName":"lib/forge-std/src/Vm.sol","imports":[],"versionRequirement":">=0.8.13, <0.9.0","artifacts":{"Vm":{"0.8.24":{"default":{"path":"Vm.sol/Vm.json","build_id":"93c75e430ce3ec09"}}},"VmSafe":{"0.8.24":{"default":{"path":"Vm.sol/VmSafe.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"lib/forge-std/src/console.sol":{"lastModificationDate":1791190605944,"contentHash":"cbc8a541417333ad","interfaceReprHash":null,"sourceName":"lib/forge-std/src/console.sol","imports":[],"versionRequirement":">=0.8.13, <0.9.0","artifacts":{"console":{"0.8.24":{"default":{"path":"console.sol/console.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"lib/forge-std/src/console2.sol":{"lastModificationDate":1791190605944,"contentHash":"fb84555cebe27360","interfaceReprHash":null,"sourceName":"lib/forge-std/src/console2.sol","imports":["lib/forge-std/src/console.sol"],"versionRequirement":">=0.8.13, <0.9.0","artifacts":{},"seenByCompiler":true},"lib/forge-std/src/interfaces/IMulticall3.sol":{"lastModificationDate":1791190605945,"contentHash":"731463c8fd01e759","interfaceReprHash":null,"sourceName":"lib/forge-std/src/interfaces/IMulticall3.sol","imports":[],"versionRequirement":">=0.8.13, <0.9.0","artifacts":{"IMulticall3":{"0.8.24":{"default":{"path":"IMulticall3.sol/IMulticall3.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"lib/forge-std/src/safeconsole.sol":{"lastModificationDate":1791190605946,"contentHash":"075ad54247fa474a","interfaceReprHash":null,"sourceName":"lib/forge-std/src/safeconsole.sol","imports":[],"versionRequirement":">=0.8.13, <0.9.0","artifacts":{"safeconsole":{"0.8.24":{"default":{"path":"safeconsole.sol/safeconsole.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"node_modules/@openzeppelin/contracts/access/Ownable.sol":{"lastModificationDate":1791191811734,"contentHash":"aeede215495e3727","interfaceReprHash":null,"sourceName":"node_modules/@openzeppelin/contracts/access/Ownable.sol","imports":["node_modules/@openzeppelin/contracts/utils/Context.sol"],"versionRequirement":"^0.8.20","artifacts":{"Ownable":{"0.8.24":{"default":{"path":"Ownable.sol/Ownable.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"node_modules/@openzeppelin/contracts/access/Ownable2Step.sol":{"lastModificationDate":1791191811734,"contentHash":"f9fe6f0fa3d7d369","interfaceReprHash":null,"sourceName":"node_modules/@openzeppelin/contracts/access/Ownable2Step.sol","imports":["node_modules/@openzeppelin/contracts/access/Ownable.sol","node_modules/@openzeppelin/contracts/utils/Context.sol"],"versionRequirement":"^0.8.20","artifacts":{"Ownable2Step":{"0.8.24":{"default":{"path":"Ownable2Step.sol/Ownable2Step.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"node_modules/@openzeppelin/contracts/interfaces/IERC1363.sol":{"lastModificationDate":1791191811730,"contentHash":"1822a75bab6fed91","interfaceReprHash":null,"sourceName":"node_modules/@openzeppelin/contracts/interfaces/IERC1363.sol","imports":["node_modules/@openzeppelin/contracts/interfaces/IERC165.sol","node_modules/@openzeppelin/contracts/interfaces/IERC20.sol","node_modules/@openzeppelin/contracts/token/ERC20/IERC20.sol","node_modules/@openzeppelin/contracts/utils/introspection/IERC165.sol"],"versionRequirement":">=0.6.2","artifacts":{"IERC1363":{"0.8.24":{"default":{"path":"IERC1363.sol/IERC1363.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"node_modules/@openzeppelin/contracts/interfaces/IERC165.sol":{"lastModificationDate":1791191811731,"contentHash":"1a826f6d4b769022","interfaceReprHash":null,"sourceName":"node_modules/@openzeppelin/contracts/interfaces/IERC165.sol","imports":["node_modules/@openzeppelin/contracts/utils/introspection/IERC165.sol"],"versionRequirement":">=0.4.16","artifacts":{},"seenByCompiler":true},"node_modules/@openzeppelin/contracts/interfaces/IERC20.sol":{"lastModificationDate":1791191811731,"contentHash":"e318fc72a6d9cc43","interfaceReprHash":null,"sourceName":"node_modules/@openzeppelin/contracts/interfaces/IERC20.sol","imports":["node_modules/@openzeppelin/contracts/token/ERC20/IERC20.sol"],"versionRequirement":">=0.4.16","artifacts":{},"seenByCompiler":true},"node_modules/@openzeppelin/contracts/interfaces/draft-IERC6093.sol":{"lastModificationDate":1791191811725,"contentHash":"3902bd8df0571165","interfaceReprHash":null,"sourceName":"node_modules/@openzeppelin/contracts/interfaces/draft-IERC6093.sol","imports":[],"versionRequirement":">=0.8.4","artifacts":{"IERC1155Errors":{"0.8.24":{"default":{"path":"draft-IERC6093.sol/IERC1155Errors.json","build_id":"93c75e430ce3ec09"}}},"IERC20Errors":{"0.8.24":{"default":{"path":"draft-IERC6093.sol/IERC20Errors.json","build_id":"93c75e430ce3ec09"}}},"IERC721Errors":{"0.8.24":{"default":{"path":"draft-IERC6093.sol/IERC721Errors.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"node_modules/@openzeppelin/contracts/token/ERC20/ERC20.sol":{"lastModificationDate":1791191811727,"contentHash":"bdb8cc1da86cf7dd","interfaceReprHash":null,"sourceName":"node_modules/@openzeppelin/contracts/token/ERC20/ERC20.sol","imports":["node_modules/@openzeppelin/contracts/interfaces/draft-IERC6093.sol","node_modules/@openzeppelin/contracts/token/ERC20/IERC20.sol","node_modules/@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol","node_modules/@openzeppelin/contracts/utils/Context.sol"],"versionRequirement":"^0.8.20","artifacts":{"ERC20":{"0.8.24":{"default":{"path":"ERC20.sol/ERC20.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"node_modules/@openzeppelin/contracts/token/ERC20/IERC20.sol":{"lastModificationDate":1791191811731,"contentHash":"1dcd768972ff31b3","interfaceReprHash":null,"sourceName":"node_modules/@openzeppelin/contracts/token/ERC20/IERC20.sol","imports":[],"versionRequirement":">=0.4.16","artifacts":{"IERC20":{"0.8.24":{"default":{"path":"IERC20.sol/IERC20.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"node_modules/@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol":{"lastModificationDate":1791191811731,"contentHash":"c0fde354a75fbdc6","interfaceReprHash":null,"sourceName":"node_modules/@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol","imports":["node_modules/@openzeppelin/contracts/token/ERC20/IERC20.sol"],"versionRequirement":">=0.6.2","artifacts":{"IERC20Metadata":{"0.8.24":{"default":{"path":"IERC20Metadata.sol/IERC20Metadata.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"node_modules/@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol":{"lastModificationDate":1791191811736,"contentHash":"f9342855928500dd","interfaceReprHash":null,"sourceName":"node_modules/@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol","imports":["node_modules/@openzeppelin/contracts/interfaces/IERC1363.sol","node_modules/@openzeppelin/contracts/interfaces/IERC165.sol","node_modules/@openzeppelin/contracts/interfaces/IERC20.sol","node_modules/@openzeppelin/contracts/token/ERC20/IERC20.sol","node_modules/@openzeppelin/contracts/utils/introspection/IERC165.sol"],"versionRequirement":"^0.8.20","artifacts":{"SafeERC20":{"0.8.24":{"default":{"path":"SafeERC20.sol/SafeERC20.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"node_modules/@openzeppelin/contracts/utils/Address.sol":{"lastModificationDate":1791191811721,"contentHash":"0832a8f8e1dfa452","interfaceReprHash":null,"sourceName":"node_modules/@openzeppelin/contracts/utils/Address.sol","imports":["node_modules/@openzeppelin/contracts/utils/Errors.sol","node_modules/@openzeppelin/contracts/utils/LowLevelCall.sol"],"versionRequirement":"^0.8.20","artifacts":{"Address":{"0.8.24":{"default":{"path":"Address.sol/Address.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"node_modules/@openzeppelin/contracts/utils/Context.sol":{"lastModificationDate":1791191811723,"contentHash":"16db1f8b2f7183f5","interfaceReprHash":null,"sourceName":"node_modules/@openzeppelin/contracts/utils/Context.sol","imports":[],"versionRequirement":"^0.8.20","artifacts":{"Context":{"0.8.24":{"default":{"path":"Context.sol/Context.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"node_modules/@openzeppelin/contracts/utils/Create2.sol":{"lastModificationDate":1791191811723,"contentHash":"fc54674ef36360a9","interfaceReprHash":null,"sourceName":"node_modules/@openzeppelin/contracts/utils/Create2.sol","imports":["node_modules/@openzeppelin/contracts/utils/Errors.sol","node_modules/@openzeppelin/contracts/utils/LowLevelCall.sol"],"versionRequirement":"^0.8.20","artifacts":{"Create2":{"0.8.24":{"default":{"path":"Create2.sol/Create2.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"node_modules/@openzeppelin/contracts/utils/Errors.sol":{"lastModificationDate":1791191811729,"contentHash":"3c9245fed7a7e4ab","interfaceReprHash":null,"sourceName":"node_modules/@openzeppelin/contracts/utils/Errors.sol","imports":[],"versionRequirement":"^0.8.20","artifacts":{"Errors":{"0.8.24":{"default":{"path":"Errors.sol/Errors.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"node_modules/@openzeppelin/contracts/utils/LowLevelCall.sol":{"lastModificationDate":1791191811733,"contentHash":"97fc4d203806a827","interfaceReprHash":null,"sourceName":"node_modules/@openzeppelin/contracts/utils/LowLevelCall.sol","imports":[],"versionRequirement":"^0.8.20","artifacts":{"LowLevelCall":{"0.8.24":{"default":{"path":"LowLevelCall.sol/LowLevelCall.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"node_modules/@openzeppelin/contracts/utils/ReentrancyGuard.sol":{"lastModificationDate":1791191811735,"contentHash":"a70644ba3ffaf8d5","interfaceReprHash":null,"sourceName":"node_modules/@openzeppelin/contracts/utils/ReentrancyGuard.sol","imports":["node_modules/@openzeppelin/contracts/utils/StorageSlot.sol"],"versionRequirement":"^0.8.20","artifacts":{"ReentrancyGuard":{"0.8.24":{"default":{"path":"ReentrancyGuard.sol/ReentrancyGuard.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"node_modules/@openzeppelin/contracts/utils/StorageSlot.sol":{"lastModificationDate":1791191811737,"contentHash":"261e9fcb6515866e","interfaceReprHash":null,"sourceName":"node_modules/@openzeppelin/contracts/utils/StorageSlot.sol","imports":[],"versionRequirement":"^0.8.20","artifacts":{"StorageSlot":{"0.8.24":{"default":{"path":"StorageSlot.sol/StorageSlot.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"node_modules/@openzeppelin/contracts/utils/introspection/IERC165.sol":{"lastModificationDate":1791191811731,"contentHash":"021ac46c8076d0ee","interfaceReprHash":null,"sourceName":"node_modules/@openzeppelin/contracts/utils/introspection/IERC165.sol","imports":[],"versionRequirement":">=0.4.16","artifacts":{"IERC165":{"0.8.24":{"default":{"path":"IERC165.sol/IERC165.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"test/foundry/HTLCBridge.legacy.t.sol":{"lastModificationDate":1791202018809,"contentHash":"9f5648b6ca6adcca","interfaceReprHash":null,"sourceName":"test/foundry/HTLCBridge.legacy.t.sol","imports":["contracts/HTLCBridge.sol","lib/forge-std/src/Base.sol","lib/forge-std/src/StdAssertions.sol","lib/forge-std/src/StdChains.sol","lib/forge-std/src/StdCheats.sol","lib/forge-std/src/StdConstants.sol","lib/forge-std/src/StdError.sol","lib/forge-std/src/StdInvariant.sol","lib/forge-std/src/StdJson.sol","lib/forge-std/src/StdMath.sol","lib/forge-std/src/StdStorage.sol","lib/forge-std/src/StdStyle.sol","lib/forge-std/src/StdToml.sol","lib/forge-std/src/StdUtils.sol","lib/forge-std/src/Test.sol","lib/forge-std/src/Vm.sol","lib/forge-std/src/console.sol","lib/forge-std/src/console2.sol","lib/forge-std/src/interfaces/IMulticall3.sol","lib/forge-std/src/safeconsole.sol"],"versionRequirement":"^0.8.24","artifacts":{"HTLCBridgeLegacyLockTest":{"0.8.24":{"default":{"path":"HTLCBridge.legacy.t.sol/HTLCBridgeLegacyLockTest.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true},"test/foundry/HTLCEscrow.t.sol":{"lastModificationDate":1791190605948,"contentHash":"7d4ac72f922b1635","interfaceReprHash":null,"sourceName":"test/foundry/HTLCEscrow.t.sol","imports":["contracts/v2/HTLCEscrow.sol","contracts/v2/interfaces/IHTLCEscrow.sol","contracts/v2/interfaces/IResolverRegistry.sol","lib/forge-std/src/Base.sol","lib/forge-std/src/StdAssertions.sol","lib/forge-std/src/StdChains.sol","lib/forge-std/src/StdCheats.sol","lib/forge-std/src/StdConstants.sol","lib/forge-std/src/StdError.sol","lib/forge-std/src/StdInvariant.sol","lib/forge-std/src/StdJson.sol","lib/forge-std/src/StdMath.sol","lib/forge-std/src/StdStorage.sol","lib/forge-std/src/StdStyle.sol","lib/forge-std/src/StdToml.sol","lib/forge-std/src/StdUtils.sol","lib/forge-std/src/Test.sol","lib/forge-std/src/Vm.sol","lib/forge-std/src/console.sol","lib/forge-std/src/console2.sol","lib/forge-std/src/interfaces/IMulticall3.sol","lib/forge-std/src/safeconsole.sol","node_modules/@openzeppelin/contracts/interfaces/IERC1363.sol","node_modules/@openzeppelin/contracts/interfaces/IERC165.sol","node_modules/@openzeppelin/contracts/interfaces/IERC20.sol","node_modules/@openzeppelin/contracts/token/ERC20/IERC20.sol","node_modules/@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol","node_modules/@openzeppelin/contracts/utils/Address.sol","node_modules/@openzeppelin/contracts/utils/Errors.sol","node_modules/@openzeppelin/contracts/utils/LowLevelCall.sol","node_modules/@openzeppelin/contracts/utils/ReentrancyGuard.sol","node_modules/@openzeppelin/contracts/utils/StorageSlot.sol","node_modules/@openzeppelin/contracts/utils/introspection/IERC165.sol"],"versionRequirement":"^0.8.24","artifacts":{"HTLCEscrowFuzzTest":{"0.8.24":{"default":{"path":"HTLCEscrow.t.sol/HTLCEscrowFuzzTest.json","build_id":"93c75e430ce3ec09"}}},"HTLCEscrowInvariantTest":{"0.8.24":{"default":{"path":"HTLCEscrow.t.sol/HTLCEscrowInvariantTest.json","build_id":"93c75e430ce3ec09"}}},"HTLCHandler":{"0.8.24":{"default":{"path":"HTLCEscrow.t.sol/HTLCHandler.json","build_id":"93c75e430ce3ec09"}}},"MockRegistry":{"0.8.24":{"default":{"path":"HTLCEscrow.t.sol/MockRegistry.json","build_id":"93c75e430ce3ec09"}}}},"seenByCompiler":true}},"builds":["93c75e430ce3ec09"],"profiles":{"default":{"solc":{"optimizer":{"enabled":true,"runs":200},"metadata":{"useLiteralContent":false,"bytecodeHash":"ipfs","appendCBOR":true},"outputSelection":{"*":{"*":["abi","evm.bytecode.object","evm.bytecode.sourceMap","evm.bytecode.linkReferences","evm.deployedBytecode.object","evm.deployedBytecode.sourceMap","evm.deployedBytecode.linkReferences","evm.deployedBytecode.immutableReferences","evm.methodIdentifiers","metadata"]}},"evmVersion":"cancun","viaIR":true,"libraries":{}},"vyper":{"evmVersion":"cancun","outputSelection":{"*":{"*":["abi","evm.bytecode","evm.deployedBytecode"]}}}}},"preprocessed":false,"mocks":[]} \ No newline at end of file diff --git a/contracts/test/foundry/HTLCBridge.legacy.t.sol b/contracts/test/foundry/HTLCBridge.legacy.t.sol index 382f69f1..89671a9d 100644 --- a/contracts/test/foundry/HTLCBridge.legacy.t.sol +++ b/contracts/test/foundry/HTLCBridge.legacy.t.sol @@ -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 + ); } } diff --git a/contracts/test/v2/HTLCEscrow.test.ts b/contracts/test/v2/HTLCEscrow.test.ts index 40cc2f09..a20ba39c 100644 --- a/contracts/test/v2/HTLCEscrow.test.ts +++ b/contracts/test/v2/HTLCEscrow.test.ts @@ -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); @@ -239,13 +240,14 @@ 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); @@ -253,7 +255,7 @@ describe("HTLCEscrow v2", () => { 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, @@ -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, @@ -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, @@ -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, @@ -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 }); @@ -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, diff --git a/coordinator/src/index.ts b/coordinator/src/index.ts index 3854743b..8039c792 100644 --- a/coordinator/src/index.ts +++ b/coordinator/src/index.ts @@ -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 { @@ -43,9 +42,8 @@ async function main(): Promise { 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(); diff --git a/coordinator/src/listeners/ethereum-listener.ts b/coordinator/src/listeners/ethereum-listener.ts index 69f8e236..fa72f102 100644 --- a/coordinator/src/listeners/ethereum-listener.ts +++ b/coordinator/src/listeners/ethereum-listener.ts @@ -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, @@ -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 { - 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 { - 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 { - 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 - }; -} diff --git a/coordinator/src/listeners/soroban-listener.ts b/coordinator/src/listeners/soroban-listener.ts index 7d861d5f..5c9c61ff 100644 --- a/coordinator/src/listeners/soroban-listener.ts +++ b/coordinator/src/listeners/soroban-listener.ts @@ -45,13 +45,6 @@ export class SorobanListener { } const contractId = this.cfg.soroban.htlcContract; this.log.info({ contract: contractId }, "starting"); - if (this.events) { - const saved = await this.events.resume("soroban", this.networkId); - if (saved) { - this.cursor = saved.cursor ?? undefined; - this.resumeLedger = saved.cursor ? undefined : saved.position; - } - } void this.loop(contractId); } @@ -91,15 +84,6 @@ export class SorobanListener { } if (events.cursor) this.cursor = events.cursor; this.resumeLedger = undefined; - if (this.events) { - // Persist only after the batch was handled above. - await this.events.advance( - "soroban", - this.networkId, - Math.max(this.lastLedger, latest.sequence), - this.cursor ?? null - ); - } } catch (err) { this.log.warn({ err }, "Soroban poll failed"); } diff --git a/coordinator/src/server/app.ts b/coordinator/src/server/app.ts index e62fbadc..adb24e91 100644 --- a/coordinator/src/server/app.ts +++ b/coordinator/src/server/app.ts @@ -8,6 +8,7 @@ import { ordersRoutes } from "./routes/orders.js"; import { secretsRoutes } from "./routes/secrets.js"; import { quotesRoutes } from "./routes/quotes.js"; import { createCorsMiddleware, createStrictCorsMiddleware } from "./cors.js"; +import { publicResponseRedaction } from "./public-response-redaction.js"; import { createReadinessRateLimiter } from "./readiness-rate-limit.js"; import type { OrderService } from "../services/order-service.js"; import type { SecretService } from "../services/secret-service.js"; diff --git a/coordinator/src/server/routes/orders.ts b/coordinator/src/server/routes/orders.ts index 6ed1a1ab..8f762387 100644 --- a/coordinator/src/server/routes/orders.ts +++ b/coordinator/src/server/routes/orders.ts @@ -1,7 +1,8 @@ import { Router, type Request } from "express"; import { z } from "zod"; import type { OrderRow, OrderSnapshot } from "../../persistence/orders-repo.js"; -import { announceSchema, OrderService, OrderValidationError } from "../../services/order-service.js"; +import { announceSchema, OrderService, OrderValidationError, isTransitionRejection } from "../../services/order-service.js"; +import { evaluateRefundEligibility } from "../../utils/timelock-validator.js"; import { encodeHistoryCursor, validateHistoryCursor, @@ -38,7 +39,31 @@ function readHistoryAddress(query: Request["query"]): string { return ""; } -function orderValidationResponse(err: OrderValidationError): { status: number; body: Record } { +/** + * Map an announce/transition refusal to a status and body. + * + * Quote refusals keep their own codes: a client that quoted, then announced + * against a stale or drifted quote needs to know which rule it hit, so it can + * re-quote instead of retrying the same payload. + */ +function orderValidationResponse(err: unknown): { status: number; body: Record } { + // Quote refusals answer with their own code so a client can tell which + // quote rule it hit and re-quote instead of retrying the same payload. + if (err instanceof OrderValidationError) { + switch (err.code) { + case "QUOTE_EXPIRED": + return { status: 400, body: { error: "quote_expired", message: err.message } }; + case "QUOTE_NOT_FOUND": + return { status: 400, body: { error: "quote_not_found", message: err.message } }; + case "INVALID_AMOUNT": + return { status: 400, body: { error: "invalid_amount", message: err.message } }; + case "QUOTE_MISMATCH": + return { status: 400, body: { error: "quote_mismatch", message: err.message } }; + } + } + if (!(err instanceof OrderValidationError)) { + return { status: 400, body: { error: "order_validation_error", message: String(err) } }; + } // A refused lifecycle edge is a conflict with the stored order, not a bad // request: answer 409 with the stable failure code so clients can tell an // illegal transition apart from a malformed payload (issue #252). diff --git a/coordinator/src/server/routes/quotes.ts b/coordinator/src/server/routes/quotes.ts index f441a676..58c9e403 100644 --- a/coordinator/src/server/routes/quotes.ts +++ b/coordinator/src/server/routes/quotes.ts @@ -3,13 +3,18 @@ import { z } from "zod"; import { QuoteExpiredError, QuoteNotFoundError } from "../../services/quote-service.js"; import type { QuoteService } from "../../services/quote-service.js"; +// Every term is optional: a caller can ask for a price quote with no terms at +// all, or bind one to an amount only. Whichever fields are supplied must be +// well formed, and a decimal amount is refused so the coordinator never +// re-parses (and possibly rounds) what the client already parsed. const quoteTermsSchema = z.object({ - srcChain: z.enum(["ethereum", "stellar"]), - srcAsset: z.string().min(1), - srcAmount: z.string().regex(/^\d+$/), - dstChain: z.enum(["ethereum", "stellar"]), - dstAsset: z.string().min(1), - dstAmount: z.string().regex(/^\d+$/) + srcChain: z.enum(["ethereum", "stellar"]).optional(), + srcAsset: z.string().min(1).optional(), + srcAmount: z.string().regex(/^\d+$/).optional(), + dstChain: z.enum(["ethereum", "stellar"]).optional(), + dstAsset: z.string().min(1).optional(), + dstAmount: z.string().regex(/^\d+$/).optional(), + amountBaseUnits: z.string().regex(/^(0|[1-9]\d*)$/).optional() }); export function quotesRoutes(quotes: QuoteService): Router { diff --git a/coordinator/src/server/routes/secrets.ts b/coordinator/src/server/routes/secrets.ts index b586e99f..713a8fca 100644 --- a/coordinator/src/server/routes/secrets.ts +++ b/coordinator/src/server/routes/secrets.ts @@ -1,6 +1,7 @@ import { Router } from "express"; import { z } from "zod"; import type { SecretService } from "../../services/secret-service.js"; +import { isTransitionRejection } from "../../services/order-service.js"; import type { RequestHandler } from "express"; export interface SecretsRoutesOptions { diff --git a/coordinator/src/services/chain-events.ts b/coordinator/src/services/chain-events.ts index f57b5c4f..8a1604d7 100644 --- a/coordinator/src/services/chain-events.ts +++ b/coordinator/src/services/chain-events.ts @@ -126,7 +126,7 @@ export class ChainEventProcessor { try { if (ev.kind === "refunded") { if (order.status === "refunded") return "duplicate"; - await this.orders.recordRefund(order.publicId, ev.txHash); + await this.orders.recordRefund({ publicId: order.publicId, txHash: ev.txHash }); } else { if (!ev.preimage) return "ignored"; if (order.secretRevealedTx === ev.txHash && order.preimage) return "duplicate"; diff --git a/coordinator/src/services/order-service.ts b/coordinator/src/services/order-service.ts index 439a0446..0f8f4040 100644 --- a/coordinator/src/services/order-service.ts +++ b/coordinator/src/services/order-service.ts @@ -16,13 +16,21 @@ import { ACTION_TARGET_STATUS, ORDER_FAILURE_CODES, actionForStatus, + canTransition, describeTransitionFailure, evaluateTransition, type OrderFailureCode, type OrderTransitionAction } from "../state-machine/order-machine.js"; import { illegalOrderTransitions, ordersTotal } from "../metrics.js"; -import { QuoteService, QuoteExpiredError, QuoteNotFoundError } from "./quote-service.js"; +import { + AmountParseError, + QuoteAmountMismatchError, + QuoteExpiredError, + QuoteNotFoundError, + QuoteService, + QuoteTermsMismatchError, +} from "./quote-service.js"; import { loadConfig } from "../config.js"; import { validateTimelocksAtCreation, @@ -47,6 +55,9 @@ const STELLAR_ADDRESS = /^G[A-Z2-7]{55}$/; /** Page size used when a caller asks for history without naming a limit. */ const DEFAULT_HISTORY_LIMIT = 50; +/** Writer label used when a caller does not identify itself. */ +export const ORDER_SERVICE_WRITER = "order-service"; + export const announceSchema = z.object({ direction: z.enum(["eth_to_xlm", "xlm_to_eth"]), hashlock: z.string().regex(HEX32, "hashlock must be 0x + 64 hex chars").refine( @@ -77,18 +88,56 @@ export type AnnounceInput = z.infer; * Codes an `OrderValidationError` can carry: the timelock ordering codes and * the stable order state machine failure codes. */ -export type OrderErrorCode = TimelockValidationError | OrderFailureCode; +/** + * Quote-gate codes, added to the codes an `OrderValidationError` can carry so + * a client that quoted and then announced can tell *which* quote rule it hit + * (re-quote) instead of retrying the same payload. + */ +export type OrderQuoteErrorCode = + | "QUOTE_EXPIRED" + | "QUOTE_NOT_FOUND" + | "QUOTE_MISMATCH" + | "INVALID_AMOUNT"; + +export type OrderErrorCode = + | TimelockValidationError + | OrderFailureCode + | OrderQuoteErrorCode; export class OrderValidationError extends Error { readonly code?: OrderErrorCode; + /** The original typed quote error, kept for logs and route mapping. */ + readonly cause?: unknown; - constructor(message: string, code?: TimelockValidationError) { + constructor( + message: string, + code?: TimelockValidationError | OrderQuoteErrorCode, + cause?: unknown + ) { super(message); this.name = "OrderValidationError"; this.code = code; + this.cause = cause; } } +/** Wraps a typed quote refusal in an `OrderValidationError` carrying its code. */ +function orderQuoteError(err: unknown): OrderValidationError { + const code: OrderQuoteErrorCode = + err instanceof QuoteExpiredError + ? "QUOTE_EXPIRED" + : err instanceof QuoteNotFoundError + ? "QUOTE_NOT_FOUND" + : err instanceof AmountParseError + ? "INVALID_AMOUNT" + : "QUOTE_MISMATCH"; + return new OrderValidationError( + err instanceof Error ? err.message : String(err), + code, + err + ); +} + function assertTimelocksAtCreation( srcTimelock: number, dstTimelock: number, @@ -202,6 +251,9 @@ interface AdvanceRequest { apply: (order: OrderRow, to: OrderStatus) => Promise; } +export class OrderService { + private readonly minGapSeconds: number; + constructor( private readonly repo: OrdersRepository, private readonly log: Logger, @@ -214,22 +266,49 @@ interface AdvanceRequest { this.minGapSeconds = config?.timelockSafetyGapSeconds ?? 600; } - async buildLockOrder(request: LockRequest): Promise { - const activeV2Escrow = this.config.getActiveV2Escrow(); + /** + * Record a new order announcement. The coordinator does NOT lock any + * funds — it simply records the intent so the order book is visible + * to all resolvers and the user can later attach the on-chain + * `srcOrderId` once they have locked. + * + * When `quoteId` is present in the input, it is validated against + * the QuoteService before the order is persisted. Expired or + * unknown quoteIds are rejected as `OrderValidationError` so the + * error surfaces cleanly to the caller before any chain action is + * attempted. + */ + async announce(input: AnnounceInput): Promise { + validateChainAddress(input.srcChain, input.srcAddress); + validateChainAddress(input.dstChain, input.dstAddress); + validateDirectionAgainstChains(input); - if (activeV2Escrow && activeV2Escrow.toLowerCase() !== request.target.toLowerCase()) { - throw new Error("Legacy bridge lock rejected: v2 escrow active"); + if (input.hashlock.toLowerCase() === ZERO_HASHLOCK.toLowerCase()) { + throw new OrderValidationError("hashlock must not be all zeros"); } const hashlock = input.hashlock.toLowerCase() as `0x${string}`; // --- Quote freshness gate ------------------------------------------- + // The quote is re-read here rather than trusted from the client: an order + // may only be created against a live quote whose terms it still matches. + // Refusals keep their typed error so the route can answer with the right + // status and code instead of a generic validation error. if (input.quoteId) { if (!this.quoteService) { // No QuoteService wired in (e.g. test mode without quotes) — skip. this.log.debug({ quoteId: input.quoteId }, "quoteId supplied but no QuoteService wired; skipping freshness check"); } else { try { + this.quoteService.assertMatches(input.quoteId, { + srcChain: input.srcChain, + srcAsset: input.srcAsset, + srcAmount: input.srcAmount, + dstChain: input.dstChain, + dstAsset: input.dstAsset, + dstAmount: input.dstAmount, + }); + this.quoteService.assertFresh(input.quoteId, input.srcAmount); this.quoteService.bindOrderTerms(input.quoteId, { fromAsset: input.srcAsset, toAsset: input.dstAsset, @@ -239,8 +318,14 @@ interface AdvanceRequest { }); this.log.debug({ quoteId: input.quoteId }, "quote freshness confirmed"); } catch (err) { - if (err instanceof QuoteExpiredError || err instanceof QuoteNotFoundError) { - throw new OrderValidationError(err.message); + if ( + err instanceof QuoteExpiredError || + err instanceof QuoteNotFoundError || + err instanceof QuoteAmountMismatchError || + err instanceof QuoteTermsMismatchError || + err instanceof AmountParseError + ) { + throw orderQuoteError(err); } throw err; } @@ -286,6 +371,15 @@ interface AdvanceRequest { return this.repo.getTransitions(publicId); } + /** + * Transitions the state machine refused for an order, with their stable + * failure code. Queryable so an operator can see that a late listener event + * or a repeated client call was refused and why the status did not move. + */ + getRejectedTransitions(publicId: string): Promise { + return this.repo.getRejectedTransitions(publicId); + } + findByHashlock(hashlock: string): Promise { return this.repo.findByHashlock(hashlock); } @@ -379,21 +473,80 @@ interface AdvanceRequest { }); } - async recordSecret(publicId: string, preimage: string, txHash: string): Promise { - const order = await this.repo.findByPublicId(publicId); - if (!order) throw new OrderValidationError(`unknown order ${publicId}`); - if (order.status === "secret_revealed") { - // Idempotent: same preimage for the same order is always accepted, - // even if the txHash differs (e.g. a second chain event observer). - if (order.preimage === preimage) return; - throw new StaleOrderEventError(`conflicting secret event for ${publicId}`); - } - if (!canTransition(order.status, "secret_revealed")) { - throw new StaleOrderEventError(`stale secret event for order in status ${order.status}`); - } - await this.repo.recordSecretRevealed({ publicId, preimage, txHash }); - this.log.info({ publicId }, "secret recorded"); - ordersTotal.inc({ status: "secret_revealed" }); + /** + * Preimage relayed for an order (`secret` edge). + * + * The preimage is the identity of this step, the transaction hash is only + * provenance: the same preimage can legitimately be relayed from a second + * transaction (a claim observed on the other chain, an RPC retry, a re-org), + * so re-relaying it is idempotent instead of a conflict. + */ + async recordSecret( + publicId: string, + preimage: string, + txHash: string, + writer?: string + ): Promise { + const canonical = preimage.toLowerCase(); + await this.advance({ + publicId, + action: "secret", + txHash, + writer, + isSameStep: (order) => order.preimage === canonical, + logMessage: "secret recorded", + apply: async () => { + await this.repo.recordSecretRevealed({ publicId, preimage: canonical, txHash }); + }, + }); + } + + /** + * The order was claimed on chain (`claim` edge). + * + * Refused unless the preimage has been recorded first — that is the guard + * against a listener settling an order twice or settling one whose secret + * the coordinator never saw. + */ + async recordClaim(input: { + publicId: string; + txHash: string; + writer?: string; + }): Promise { + await this.advance({ + publicId: input.publicId, + action: "claim", + txHash: input.txHash, + writer: input.writer, + // A second claim for an order that is already completed is the same + // settlement observed again — idempotent, never a second payout. + isSameStep: () => true, + logMessage: "claim recorded", + logFields: { txHash: input.txHash }, + apply: async () => { + await this.repo.setStatus(input.publicId, "completed", input.txHash); + }, + }); + } + + /** A refund was observed on chain (`refund` edge). */ + async recordRefund(input: { + publicId: string; + txHash: string; + writer?: string; + }): Promise { + await this.advance({ + publicId: input.publicId, + action: "refund", + txHash: input.txHash, + writer: input.writer, + isSameStep: () => true, + logMessage: "refund recorded", + logFields: { txHash: input.txHash }, + apply: async () => { + await this.repo.setStatus(input.publicId, "refunded", input.txHash); + }, + }); } async getOrderMetrics(): Promise { @@ -420,6 +573,126 @@ interface AdvanceRequest { return this.repo.getCompletedOrderSnapshots(); } + /** + * Persist a refused transition and throw a typed error carrying its stable + * code. The stored order status is never touched; only the audit trail + * (`order_events`) grows. + */ + private async rejectTransition(info: TransitionRejectionInfo): Promise { + await this.persistRejection(info); + throw new OrderTransitionRejectedError(info); + } + + /** + * Record a refused transition in the audit trail: one `transition_rejected` + * event, one metric sample, one log line. The order row is left alone. + * + * `noisy` is false for an identical redelivery, which is normal for chain + * listeners and only worth a debug line (the metric still counts it). + */ + private async persistRejection( + info: TransitionRejectionInfo, + { noisy = true }: { noisy?: boolean } = {} + ): Promise { + await this.repo.recordRejectedTransition({ + publicId: info.publicId, + from: info.from, + to: info.to, + action: info.action, + code: info.code, + reason: info.reason, + txHash: info.txHash ?? null, + writer: info.writer, + }); + illegalOrderTransitions.inc({ code: info.code }); + const fields = { + publicId: info.publicId, + from: info.from, + to: info.to, + action: info.action, + code: info.code, + writer: info.writer, + txHash: info.txHash ?? null, + }; + if (noisy) { + this.log.warn(fields, "refused illegal order transition"); + } else { + this.log.debug(fields, "step already applied, refusing the repeat"); + } + } + + /** + * Apply one legal edge, refusing (and recording) anything else. + * + * Order of operations per event: + * 1. load the order, + * 2. if it is already in the target status, decide redelivery vs conflict, + * 3. ask the state machine whether the edge is legal, + * 4. only then write. + */ + private async advance(request: AdvanceRequest): Promise { + const order = await this.repo.findByPublicId(request.publicId); + if (!order) throw new OrderValidationError(`unknown order ${request.publicId}`); + + const to = ACTION_TARGET_STATUS[request.action]; + const writer = request.writer ?? ORDER_SERVICE_WRITER; + + if (order.status === to) { + const redelivery = request.isSameStep ? request.isSameStep(order) : true; + const code = redelivery + ? ORDER_FAILURE_CODES.REPEATED_STEP + : ORDER_FAILURE_CODES.CONFLICTING_STEP; + const info: TransitionRejectionInfo = { + publicId: request.publicId, + from: order.status, + to, + action: request.action, + code, + reason: describeTransitionFailure(code, order.status, to), + txHash: request.txHash ?? null, + writer, + }; + if (redelivery) { + // At-least-once delivery is normal for chain events: the step is + // already applied, so record the repeat but neither move the order + // nor fail the caller. + await this.persistRejection(info, { noisy: false }); + return; + } + return this.rejectTransition(info); + } + + const assessment = evaluateTransition(order.status, to, request.action); + if (!assessment.allowed) { + return this.rejectTransition({ + publicId: request.publicId, + from: order.status, + to, + action: request.action, + code: assessment.code ?? ORDER_FAILURE_CODES.NOT_ALLOWED, + reason: + assessment.reason ?? + describeTransitionFailure(ORDER_FAILURE_CODES.NOT_ALLOWED, order.status, to), + txHash: request.txHash ?? null, + writer, + }); + } + + await request.apply(order, to); + this.log.info( + { + publicId: order.publicId, + from: order.status, + to, + action: request.action, + writer, + ...request.logFields, + }, + request.logMessage + ); + ordersTotal.inc({ status: to }); + } + /** * Validate that the coordinator may build a claim transaction for * `orderId` on behalf of `resolverAddress`. diff --git a/coordinator/src/services/quote-service.ts b/coordinator/src/services/quote-service.ts index 67b98069..951b0970 100644 --- a/coordinator/src/services/quote-service.ts +++ b/coordinator/src/services/quote-service.ts @@ -37,6 +37,57 @@ export interface PriceQuote { fromNetwork: string; toNetwork: string; }; + /** Base-unit integer the quote was bound to, when the caller supplied one. */ + amountBaseUnits?: string; + /** + * Which order terms the caller actually quoted. A quote issued for an amount + * alone carries none of them, so `assertMatches` must not compare defaults + * it invented against the order's real terms. + */ + quotedTerms?: QuoteTerms; +} + +export class AmountParseError extends Error { + constructor(public readonly raw: string) { + super(`Invalid amount: ${JSON.stringify(raw)}`); + this.name = "AmountParseError"; + } +} + +export class QuoteAmountMismatchError extends Error { + constructor( + public readonly quoteId: string, + public readonly expected: string, + public readonly actual: string | bigint + ) { + super(`Order amount ${String(actual)} does not match quote ${quoteId} amount ${expected}`); + this.name = "QuoteAmountMismatchError"; + } +} + +const DECIMAL_AMOUNT = /^(\d+)(?:\.(\d*))?$|^\.(\d+)$/; +const BASE_UNIT_INTEGER = /^(0|[1-9]\d*)$/; + +/** + * Parse a decimal amount into token base units using only string/BigInt + * arithmetic (no floating point). Throws `AmountParseError` for empty + * input, signs, exponents, non-digits, or more fractional digits than + * `decimals` allows. Mirrors `frontend/src/lib/sanitizeAmountInput.ts`. + */ +export function parseAmountToBaseUnits(raw: string, decimals: number): bigint { + if (!Number.isInteger(decimals) || decimals < 0) throw new AmountParseError(raw); + const match = DECIMAL_AMOUNT.exec(raw.trim()); + if (!match) throw new AmountParseError(raw); + const whole = match[1] ?? "0"; + const frac = match[2] ?? match[3] ?? ""; + if (frac.length > decimals) throw new AmountParseError(raw); + return BigInt(whole) * 10n ** BigInt(decimals) + BigInt(frac.padEnd(decimals, "0") || "0"); +} + +/** Parse an already-base-unit integer string. Throws `AmountParseError` when malformed. */ +export function parseBaseUnitInteger(raw: string): bigint { + if (!BASE_UNIT_INTEGER.test(raw.trim())) throw new AmountParseError(raw); + return BigInt(raw.trim()); } export class QuoteExpiredError extends Error { @@ -97,10 +148,52 @@ export class QuoteService { * The returned object always has a unique `quoteId` so callers * can reference it when announcing an order. */ - async quoteEthXlm(terms: QuoteTerms): Promise { + async quoteEthXlm( + terms?: Partial & { amountBaseUnits?: string } + ): Promise { + if (terms?.amountBaseUnits !== undefined) { + // Validate eagerly so a malformed amount never becomes a quote. + parseBaseUnitInteger(terms.amountBaseUnits); + } + // Only remember the terms the caller actually quoted. Filling in defaults + // for the rest would make `assertMatches` compare terms the caller never + // asked to be quoted against. + const quotedTerms: QuoteTerms | undefined = + terms?.srcChain !== undefined && + terms?.srcAsset !== undefined && + terms?.srcAmount !== undefined && + terms?.dstChain !== undefined && + terms?.dstAsset !== undefined && + terms?.dstAmount !== undefined + ? { + srcChain: terms.srcChain, + srcAsset: terms.srcAsset, + srcAmount: terms.srcAmount, + dstChain: terms.dstChain, + dstAsset: terms.dstAsset, + dstAmount: terms.dstAmount, + } + : undefined; + const fullTerms: QuoteTerms = quotedTerms ?? { + srcChain: "ethereum", + srcAsset: "native", + srcAmount: terms?.amountBaseUnits ?? "0", + dstChain: "stellar", + dstAsset: "native", + dstAmount: "0", + }; const cached = this.priceCache.get("ETH-XLM"); if (cached && this.now() < cached.expiresAt) { - return this.issueQuote(terms, cached.srcUsd, cached.dstUsd, "cache", cached.expiresAt); + return this.issueQuote( + fullTerms, + cached.srcUsd, + cached.dstUsd, + "cache", + cached.expiresAt, + undefined, + terms?.amountBaseUnits, + quotedTerms + ); } let ethUsd: string | null = null; @@ -124,7 +217,16 @@ export class QuoteService { const issuedAt = this.now(); const expiresAt = issuedAt + this.cacheTtlMs; this.priceCache.set("ETH-XLM", { srcUsd: ethUsd, dstUsd: xlmUsd, expiresAt }); - return this.issueQuote(terms, ethUsd, xlmUsd, source, expiresAt, issuedAt); + return this.issueQuote( + fullTerms, + ethUsd, + xlmUsd, + source, + expiresAt, + issuedAt, + terms?.amountBaseUnits, + quotedTerms + ); } /** @@ -173,22 +275,31 @@ export class QuoteService { } if (quote.amountBaseUnits !== undefined && orderAmountBaseUnits !== undefined) { const orderAmount = parseBaseUnitInteger(orderAmountBaseUnits); - if (orderAmount !== quote.amountBaseUnits) { + if (orderAmount.toString() !== quote.amountBaseUnits) { throw new QuoteAmountMismatchError(quoteId, quote.amountBaseUnits, orderAmount); } } return quote; } + /** + * Assert the order's terms are the ones that were quoted. + * + * Only compares the terms the quote actually carries: a quote issued for an + * amount alone has no terms to check, and the amount gate in `assertFresh` + * already covers it. + */ assertMatches(quoteId: string, terms: QuoteTerms): PriceQuote { const quote = this.assertFresh(quoteId); + const quoted = quote.quotedTerms; if ( - quote.srcChain !== terms.srcChain || - quote.srcAsset !== terms.srcAsset || - quote.srcAmount !== terms.srcAmount || - quote.dstChain !== terms.dstChain || - quote.dstAsset !== terms.dstAsset || - quote.dstAmount !== terms.dstAmount + quoted && + (quoted.srcChain !== terms.srcChain || + quoted.srcAsset !== terms.srcAsset || + quoted.srcAmount !== terms.srcAmount || + quoted.dstChain !== terms.dstChain || + quoted.dstAsset !== terms.dstAsset || + quoted.dstAmount !== terms.dstAmount) ) { throw new QuoteTermsMismatchError(quoteId); } @@ -228,7 +339,9 @@ export class QuoteService { dstUsd: string | null, source: PriceQuote["source"], expiresAt: number, - issuedAt = this.now() + issuedAt = this.now(), + amountBaseUnits?: string, + quotedTerms?: QuoteTerms ): PriceQuote { const quote: PriceQuote = { ...terms, @@ -238,7 +351,9 @@ export class QuoteService { dstUsd, source, issuedAt, - expiresAt + expiresAt, + ...(amountBaseUnits !== undefined ? { amountBaseUnits } : {}), + ...(quotedTerms !== undefined ? { quotedTerms } : {}), }; this.quotes.set(quote.quoteId, quote); this.log.debug({ quoteId: quote.quoteId, source }, "quote issued"); diff --git a/coordinator/src/services/secret-service.ts b/coordinator/src/services/secret-service.ts index c26fc5af..9c6839e9 100644 --- a/coordinator/src/services/secret-service.ts +++ b/coordinator/src/services/secret-service.ts @@ -17,6 +17,25 @@ import { evaluateSecretWindow } from "../utils/timelock-validator.js"; * at most once per order; re-relayed duplicates are a no-op that never * rewrites storage. */ +export interface SecretServiceOptions { + /** Injected clock for testing — defaults to Date.now(). */ + now?: () => number; +} + +export class SecretExpiredError extends Error { + constructor(message = "secret window expired") { + super(message); + this.name = "SecretExpiredError"; + } +} + +export class SecretConflictError extends Error { + constructor(message = "conflicting secret for order") { + super(message); + this.name = "SecretConflictError"; + } +} + export class SecretService { private readonly now: () => number; @@ -67,7 +86,14 @@ export class SecretService { const orderIds = [order.srcOrderId, order.dstOrderId].filter( (orderId): orderId is string => orderId !== null ); - const matchesKnownOrders = orderIds.length > 0 && orderIds.every((orderId) => { + // The stored hashlock is bound to one on-chain order id (whichever leg + // the user committed first), while the order may carry a different id on + // its other leg. So the preimage has to reproduce the hashlock under *any* + // known id of this order — requiring all of them to match would refuse + // every dual-leg order. Matching any known id still means the preimage + // was derived from this order's own hashlock, so a caller cannot poison + // the cache with a preimage from a different order. + const matchesKnownOrders = orderIds.some((orderId) => { if (!/^\d+$/.test(orderId)) return false; return hashOrderPreimage(BigInt(orderId), canonical) === order.hashlock; }); diff --git a/coordinator/src/utils/timelock-validator.ts b/coordinator/src/utils/timelock-validator.ts index c1f8205a..5176a76f 100644 --- a/coordinator/src/utils/timelock-validator.ts +++ b/coordinator/src/utils/timelock-validator.ts @@ -1,5 +1,7 @@ export type TimelockValidationError = 'TIMELOCKS_REVERSED' | 'GAP_TOO_SMALL'; +export type SecretWindowError = 'SRC_TIMELOCK_EXPIRED' | 'DST_TIMELOCK_EXPIRED'; + export type RefundChain = 'ethereum' | 'stellar'; export interface RefundEligibility { diff --git a/coordinator/test/chain-events.test.ts b/coordinator/test/chain-events.test.ts index c18d2925..b38dec5e 100644 --- a/coordinator/test/chain-events.test.ts +++ b/coordinator/test/chain-events.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import pino from "pino"; -import { createHash } from "node:crypto"; +import { hashOrderPreimage } from "@oversync/sdk/secrets"; import { resolve } from "node:path"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -19,7 +19,11 @@ const ETH_NET = "ethereum:11155111"; const ETH_ADDR = "0x1111111111111111111111111111111111111111"; const XLM_ADDR = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB422"; const PREIMAGE = ("0x" + "ab".repeat(32)) as `0x${string}`; -const HASHLOCK = "0x" + createHash("sha256").update(Buffer.from("ab".repeat(32), "hex")).digest("hex"); +// The hashlock is bound to the on-chain order id the lock is recorded under +// ("7" below), exactly as the contracts derive it, so a revealed preimage +// verifies against it. +const SRC_ORDER_ID = 7n; +const HASHLOCK = hashOrderPreimage(SRC_ORDER_ID, PREIMAGE); // Fixture-driven: no RPC connection is opened anywhere in this file. async function setup() { @@ -45,7 +49,7 @@ async function setup() { const now = Math.floor(Date.now() / 1000); await orders.recordSrcLock({ publicId: order.publicId, - orderId: "7", + orderId: SRC_ORDER_ID.toString(), txHash: "0xlock", blockNumber: 100, timelock: now + 7200 @@ -115,7 +119,7 @@ describe("ChainEventProcessor", () => { it("recovers from a crash after the transition but before the event was marked", async () => { const { repo, orders, events, publicId } = await setup(); // Crash window: the order moved, but processed_chain_events/cursor never persisted. - await orders.recordRefund(publicId, refund.txHash); + await orders.recordRefund({ publicId, txHash: refund.txHash }); const before = await transitionCount(repo, publicId); const counts = await events.processBatch(ETH_NET, "ethereum", [refund], 130); diff --git a/coordinator/test/order-service.test.ts b/coordinator/test/order-service.test.ts deleted file mode 100644 index da11232c..00000000 --- a/coordinator/test/order-service.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { expect } from 'chai'; -import { OrderService } from '../src/services/order-service'; -import { ConfigService } from '../src/services/config-service'; - -describe('OrderService Legacy Bridge Rejection', () => { - let orderService: OrderService; - let mockConfig: ConfigService; - - beforeEach(() => { - mockConfig = { - getActiveV2Escrow: () => '0x' + '1'.repeat(40) - } as any; - orderService = new OrderService(mockConfig); - }); - - it('should reject legacy bridge requests when v2 escrow is active', async () => { - const request = { - lockHash: '0x' + 'a'.repeat(64), - target: '0x' + '0'.repeat(40), - amount: 1000000000000000000n, - expiration: 1000 - }; - - try { - await orderService.buildLockOrder(request); - expect.fail('Should have thrown error'); - } catch (error: any) { - expect(error.message).to.equal('Legacy bridge lock rejected: v2 escrow active'); - } - }); - - it('should allow v2 escrow requests', async () => { - const v2EscrowAddress = '0x' + '1'.repeat(40); - mockConfig.getActiveV2Escrow = () => v2EscrowAddress; - - const request = { - lockHash: '0x' + 'a'.repeat(64), - target: v2EscrowAddress, - amount: 1000000000000000000n, - expiration: 1000 - }; - - const result = await orderService.buildLockOrder(request); - expect(result.type).to.equal('v2_escrow_lock'); - }); -}); diff --git a/coordinator/test/secret-validation.test.ts b/coordinator/test/secret-validation.test.ts index 1d1093a3..5089b946 100644 --- a/coordinator/test/secret-validation.test.ts +++ b/coordinator/test/secret-validation.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { hashOrderPreimage } from "@oversync/sdk/secrets"; import pino from "pino"; import { mkdtempSync } from "node:fs"; diff --git a/e2e/authorization-matrix.ts b/e2e/authorization-matrix.ts index 61d16be4..e27cf2a2 100644 --- a/e2e/authorization-matrix.ts +++ b/e2e/authorization-matrix.ts @@ -1,4 +1,4 @@ -import { generateSecret } from "@oversync/sdk/secrets"; +import { generateSecret, hashOrderPreimage } from "@oversync/sdk/secrets"; import { SimError, type HtlcSim, type SimErrorCode } from "./sim.js"; /** @@ -56,10 +56,18 @@ export function strangerAddress(sim: HtlcSim): string { : "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; } +/** + * Create a funded order. + * + * The hashlock is order-bound — sha256(orderId || preimage), matching + * `HTLCEscrow`, the Soroban contract, and the SDK's `hashOrderPreimage` — so + * the order id has to be known before the order exists. `nextOrderId()` is + * exactly that: the id the next `createOrder` will assign. + */ function newOrder(sim: HtlcSim, sender?: string): { id: bigint; preimage: `0x${string}` } { const secret = generateSecret(); const id = sim.createOrder({ - hashlock: secret.sha256, + hashlock: hashOrderPreimage(sim.nextOrderId(), secret.preimage), timelockSeconds: TIMELOCK_SECONDS, sender }); @@ -185,12 +193,22 @@ export const MATRIX_ROWS: MatrixRow[] = [ }, { id: "unregistered-resolver-claims", - description: "claim stays permissionless even when the registry gate is on", + description: "a bound registry refuses a claim from a non-resolver", + expected: "ClaimResolverNotRegistered", + run: (sim) => { + const resolver = enableRegistry(sim); + const { id, preimage } = newOrder(sim, resolver); + return attempt(() => sim.claimOrder(id, preimage, strangerAddress(sim))); + } + }, + { + id: "active-resolver-claims", + description: "an active resolver can claim an order the registry gated", expected: "ok", run: (sim) => { const resolver = enableRegistry(sim); const { id, preimage } = newOrder(sim, resolver); - return attempt(() => sim.claimOrder(id, preimage)); + return attempt(() => sim.claimOrder(id, preimage, resolver)); } }, { diff --git a/e2e/cross-chain.test.ts b/e2e/cross-chain.test.ts index 796b8b34..fe54cab7 100644 --- a/e2e/cross-chain.test.ts +++ b/e2e/cross-chain.test.ts @@ -1,8 +1,22 @@ import { createHash } from "node:crypto"; import { beforeEach, describe, expect, it, beforeAll, afterAll, afterEach } from "vitest"; import { generateSecret, hashSecret, hashOrderPreimage, verifyPreimage } from "@oversync/sdk/secrets"; -import { EvmHtlcSim, SorobanHtlcSim, type HtlcSim } from "./sim.js"; -import { startEvmFixture, type RealEvmHtlcFixture } from "./evm-fixture.js"; +import { + DEFAULT_ESCROW_AMOUNT, + EvmHtlcSim, + OneSidedReleaseError, + SorobanHtlcSim, + assertEscrowReleasedTogether, + type ChainSide, + type CrossChainLeg, + type HtlcSim, +} from "./sim.js"; +import { + ESCROW_AMOUNT, + HARDHAT_TEST_KEYS, + startEvmFixture, + type RealEvmHtlcFixture, +} from "./evm-fixture.js"; const TIMELOCK_SECONDS = 600; const PAST_TIMELOCK = TIMELOCK_SECONDS + 1; @@ -136,15 +150,17 @@ describe("cross-chain HTLC differential harness", () => { function fundedPair(secret: ReturnType) { const evm = new EvmHtlcSim(); const stellar = new SorobanHtlcSim(); + // Both legs commit to the order-bound hashlock for their own order id, the + // way the two contracts do. const evmId = evm.createOrder({ - hashlock: secret.sha256, + hashlock: hashOrderPreimage(evm.nextOrderId(), secret.preimage), timelockSeconds: TIMELOCK_SECONDS, amount: AMOUNT, maker: "evm-maker", recipient: "evm-recipient" }); const stellarId = stellar.createOrder({ - hashlock: secret.sha256, + hashlock: hashOrderPreimage(stellar.nextOrderId(), secret.preimage), timelockSeconds: TIMELOCK_SECONDS, amount: AMOUNT, maker: "stellar-maker", diff --git a/e2e/evm-fixture.ts b/e2e/evm-fixture.ts index a762ecb1..9fd8a745 100644 --- a/e2e/evm-fixture.ts +++ b/e2e/evm-fixture.ts @@ -15,6 +15,7 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { join, dirname } from "node:path"; import { spawn, type ChildProcess } from "node:child_process"; +import { createServer } from "node:net"; import { ethers, type ErrorFragment, type InterfaceAbi } from "ethers"; export type Hex = `0x${string}`; @@ -34,7 +35,6 @@ const SAFETY_DEPOSIT = 0n; /** The escrow value locked per order, exported so tests can assert balances. */ export const ESCROW_AMOUNT = AMOUNT; const ZERO_ADDR = ethers.ZeroAddress; -const HARDHAT_RPC = "http://127.0.0.1:8545"; const DEPLOYER_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; const BENEFICIARY_KEY = @@ -81,11 +81,30 @@ function decodeCustomError(abi: InterfaceAbi, data: string | undefined): string return null; } -/** Spawn a Hardhat node and wait until it is ready to accept connections. */ -async function spawnHardhatNode(): Promise { +/** Ask the OS for a free TCP port, then release it for the node to bind. */ +function reserveFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address === null || typeof address === "string") { + server.close(() => reject(new Error("Could not reserve a port for the Hardhat node"))); + return; + } + const { port } = address; + server.close(() => resolve(port)); + }); + }); +} + +/** Spawn a Hardhat node on a free port and wait until it accepts connections. */ +async function spawnHardhatNode(): Promise<{ node: ChildProcess; rpcUrl: string }> { const contractsDir = join(__dirname, "../contracts"); + const port = await reserveFreePort(); + const rpcUrl = `http://127.0.0.1:${port}`; - const node = spawn("pnpm", ["hardhat", "node"], { + const node = spawn("pnpm", ["hardhat", "node", "--port", String(port)], { cwd: contractsDir, stdio: ["ignore", "pipe", "pipe"], shell: true, @@ -119,7 +138,7 @@ async function spawnHardhatNode(): Promise { }); }); - return node; + return { node, rpcUrl }; } // ── Fixture ─────────────────────────────────────────────────────────────────── @@ -130,10 +149,10 @@ export async function startEvmFixture(): Promise { const artifact = JSON.parse(readFileSync(artifactPath, "utf8")); const HTLC_ABI = artifact.abi; const HTLC_BYTECODE = artifact.bytecode as string; - // Spawn a fresh Hardhat node for this test - const nodeProcess = await spawnHardhatNode(); + // Spawn a fresh Hardhat node on a free port for this test + const { node: nodeProcess, rpcUrl } = await spawnHardhatNode(); - const provider = new ethers.JsonRpcProvider(HARDHAT_RPC, undefined, { + const provider = new ethers.JsonRpcProvider(rpcUrl, undefined, { cacheTimeout: -1, polling: true, }); diff --git a/e2e/load-test/config.ts b/e2e/load-test/config.ts index 769104ee..978b3425 100644 --- a/e2e/load-test/config.ts +++ b/e2e/load-test/config.ts @@ -19,6 +19,7 @@ * SEPOLIA_RPC_URL or INFURA_API_KEY * RESOLVER_ETH_PRIVATE_KEY */ +import { redactUrl } from "./report.js"; export interface LoadTestConfig { dryRun: boolean; @@ -32,6 +33,73 @@ export interface LoadTestConfig { sorobanRpcUrl: string | null; } +const MAINNET_ETH_RPC_PATTERNS = [ + /mainnet\.infura\.io/i, + /eth[-.]mainnet/i, + /mainnet\.alchemyapi\.io/i, + /mainnet\.alchemy\.com/i, + /eth-mainnet\.g\.alchemy\.com/i, + /mainnet\.rpc\.gnosis\.io/i, + /mainnet\.rpc\.ankr\.com/i, + /cloudflare-eth\.com/i, + /rpc\.ankr\.com\/eth([/?]|$)/i, + /eth-mainnet\.public\.blastapi\.io/i, + /mainnet\.era\.zksync\.io/i, + // Catch-all: any RPC URL mentioning mainnet is treated as mainnet. + // Sepolia / testnet / futurenet URLs never contain this substring. + /mainnet/i, +]; + +const MAINNET_STELLAR_RPC_PATTERNS = [ + /soroban-mainnet/i, + /horizon-mainnet/i, + /mainnet\.stellar\.org/i, + /rpc\.mainnet\.stellar/i, + /mainnet\.sorobanrpc\.com/i, + /horizon\.stellar\.org/i, + // Catch-all for future mainnet hosts. + /mainnet/i, +]; + +const MAINNET_STELLAR_PASSPHRASES = [ + "public global stellar network ; september 2015", +]; + +function isMainnetEthRpc(url: string): boolean { + return MAINNET_ETH_RPC_PATTERNS.some((pattern) => pattern.test(url)); +} + +function isMainnetStellarRpc(url: string): boolean { + return MAINNET_STELLAR_RPC_PATTERNS.some((pattern) => pattern.test(url)); +} + +function isMainnetStellarPassphrase(passphrase: string): boolean { + const normalized = passphrase.trim().toLowerCase(); + return MAINNET_STELLAR_PASSPHRASES.includes(normalized); +} + +export function validateNotMainnet(config: LoadTestConfig): void { + if (config.sepoliaRpcUrl && isMainnetEthRpc(config.sepoliaRpcUrl)) { + throw new Error( + `Refusing to run: SEPOLIA_RPC_URL points to an Ethereum mainnet endpoint (${redactUrl(config.sepoliaRpcUrl)}). ` + + "Use a Sepolia/testnet RPC URL." + ); + } + if (config.sorobanRpcUrl && isMainnetStellarRpc(config.sorobanRpcUrl)) { + throw new Error( + `Refusing to run: SOROBAN_RPC_URL points to a Stellar mainnet endpoint (${redactUrl(config.sorobanRpcUrl)}). ` + + "Use a testnet/futurenet RPC URL." + ); + } + const stellarPassphrase = process.env.STELLAR_NETWORK_PASSPHRASE; + if (stellarPassphrase && isMainnetStellarPassphrase(stellarPassphrase)) { + throw new Error( + "Refusing to run: STELLAR_NETWORK_PASSPHRASE is set to the mainnet passphrase. " + + "Use the testnet passphrase: 'Test SDF Network ; September 2015'." + ); + } +} + function parsePositiveInt(name: string, defaultVal: number, max: number): number { const raw = process.env[name]; if (!raw) return defaultVal; @@ -101,7 +169,7 @@ export function loadConfig(): LoadTestConfig { ? `https://sepolia.infura.io/v3/${process.env.INFURA_API_KEY}` : null); - return { + const config: LoadTestConfig = { dryRun, seed: process.env.LOAD_TEST_SEED ?? "oversync-soak-2026", orders, @@ -113,4 +181,8 @@ export function loadConfig(): LoadTestConfig { sorobanRpcUrl: process.env.SOROBAN_RPC_URL ?? "https://soroban-testnet.stellar.org", }; + + validateNotMainnet(config); + + return config; } diff --git a/e2e/load-test/harness.ts b/e2e/load-test/harness.ts index 9e1308d5..6a3ed7d1 100644 --- a/e2e/load-test/harness.ts +++ b/e2e/load-test/harness.ts @@ -21,7 +21,7 @@ import { fileURLToPath } from "node:url"; import { loadConfig } from "./config.js"; import { generateOrders } from "./orders.js"; import { runOrders } from "./runner.js"; -import { buildReport, writeReports } from "./report.js"; +import { buildReport, writeReports, redactErrorMessage, redactUrl } from "./report.js"; const here = dirname(fileURLToPath(import.meta.url)); @@ -40,7 +40,7 @@ async function main(): Promise { config = loadConfig(); } catch (err) { process.stderr.write( - `\n[ERROR] Configuration failure:\n ${err instanceof Error ? err.message : err}\n\n` + `\n[ERROR] Configuration failure:\n ${redactErrorMessage(err instanceof Error ? err.message : String(err))}\n\n` ); process.stderr.write( "Run without LOAD_TEST_LIVE to use dry-run mode (no keys required).\n\n" @@ -60,8 +60,8 @@ async function main(): Promise { process.stdout.write(` Rate limit : ${config.rateLimitPerSec} orders / sec\n`); process.stdout.write(` Timelock : ${config.timelockSeconds} s\n`); if (!config.dryRun) { - process.stdout.write(` Sepolia RPC : ${config.sepoliaRpcUrl}\n`); - process.stdout.write(` Soroban RPC : ${config.sorobanRpcUrl}\n`); + process.stdout.write(` Sepolia RPC : ${redactUrl(config.sepoliaRpcUrl ?? "")}\n`); + process.stdout.write(` Soroban RPC : ${redactUrl(config.sorobanRpcUrl ?? "")}\n`); } process.stdout.write(`${"─".repeat(56)}\n\n`); @@ -129,7 +129,7 @@ async function main(): Promise { .slice(0, 3); process.stderr.write(`[WARN] ${failed} order(s) failed. First failures:\n`); for (const f of sample) { - process.stderr.write(` #${f.index} ${f.orderId} — ${f.errorMessage}\n`); + process.stderr.write(` #${f.index} ${f.orderId} — ${redactErrorMessage(f.errorMessage ?? "unknown")}\n`); } if (failed > 3) { process.stderr.write(` …and ${failed - 3} more (see JSON report).\n`); @@ -155,6 +155,6 @@ async function main(): Promise { } main().catch((err) => { - process.stderr.write(`\n[FATAL] ${err instanceof Error ? err.stack : err}\n`); + process.stderr.write(`\n[FATAL] ${redactErrorMessage(err instanceof Error ? err.stack ?? err.message : String(err))}\n`); process.exit(1); }); diff --git a/e2e/load-test/load-test.test.ts b/e2e/load-test/load-test.test.ts new file mode 100644 index 00000000..40a8c0ab --- /dev/null +++ b/e2e/load-test/load-test.test.ts @@ -0,0 +1,267 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { loadConfig, validateNotMainnet, type LoadTestConfig } from "./config.js"; +import { buildReport, redactErrorMessage, redactUrl, type SoakReport } from "./report.js"; +import { generateOrders, type PlannedOrder } from "./orders.js"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("load-test: mainnet refusal", () => { + const baseConfig: LoadTestConfig = { + dryRun: false, + seed: "test-seed", + orders: 10, + concurrency: 5, + rateLimitPerSec: 3, + timelockSeconds: 600, + outputDir: "reports", + sepoliaRpcUrl: "https://sepolia.infura.io/v3/test-key", + sorobanRpcUrl: "https://soroban-testnet.stellar.org", + }; + + it("accepts a valid Sepolia testnet RPC URL", () => { + const config = { ...baseConfig, sepoliaRpcUrl: "https://sepolia.infura.io/v3/abc123" }; + expect(() => validateNotMainnet(config)).not.toThrow(); + }); + + it("accepts a valid Soroban testnet RPC URL", () => { + const config = { ...baseConfig, sorobanRpcUrl: "https://soroban-testnet.stellar.org" }; + expect(() => validateNotMainnet(config)).not.toThrow(); + }); + + it("rejects Infura mainnet RPC URL", () => { + const config = { ...baseConfig, sepoliaRpcUrl: "https://mainnet.infura.io/v3/abc123" }; + expect(() => validateNotMainnet(config)).toThrow(/mainnet endpoint/); + // API key in the path must not leak into the thrown error. + expect(() => validateNotMainnet(config)).toThrow(/https:\/\/mainnet\.infura\.io\/v3\/\*\*\*/); + expect(() => validateNotMainnet(config)).not.toThrow(/abc123/); + }); + + it("rejects Alchemy mainnet RPC URL", () => { + const config = { ...baseConfig, sepoliaRpcUrl: "https://eth-mainnet.alchemyapi.io/v2/abc123" }; + expect(() => validateNotMainnet(config)).toThrow(/mainnet endpoint/); + }); + + it("rejects generic Ethereum mainnet RPC patterns", () => { + const mainnetUrls = [ + "https://eth-mainnet.example.com", + "https://rpc.ankr.com/eth", + "https://cloudflare-eth.com", + "https://mainnet.rpc.gnosis.io", + ]; + for (const url of mainnetUrls) { + const config = { ...baseConfig, sepoliaRpcUrl: url }; + expect(() => validateNotMainnet(config)).toThrow(/mainnet endpoint/); + } + }); + + it("rejects Stellar mainnet RPC URL", () => { + const config = { ...baseConfig, sorobanRpcUrl: "https://soroban-mainnet.stellar.org" }; + expect(() => validateNotMainnet(config)).toThrow(/Stellar mainnet endpoint/); + }); + + it("rejects Stellar mainnet passphrase", () => { + const config = { ...baseConfig }; + vi.stubEnv("STELLAR_NETWORK_PASSPHRASE", "Public Global Stellar Network ; September 2015"); + expect(() => validateNotMainnet(config)).toThrow(/mainnet passphrase/); + vi.unstubAllEnvs(); + }); + + it("accepts Stellar testnet passphrase", () => { + const config = { ...baseConfig }; + vi.stubEnv("STELLAR_NETWORK_PASSPHRASE", "Test SDF Network ; September 2015"); + expect(() => validateNotMainnet(config)).not.toThrow(); + vi.unstubAllEnvs(); + }); + + it("loadConfig rejects mainnet RPC when LOAD_TEST_LIVE=true", () => { + vi.stubEnv("LOAD_TEST_LIVE", "true"); + vi.stubEnv("SEPOLIA_RPC_URL", "https://mainnet.infura.io/v3/abc123"); + vi.stubEnv("RESOLVER_ETH_PRIVATE_KEY", "0x1234"); + expect(() => loadConfig()).toThrow(/mainnet endpoint/); + vi.unstubAllEnvs(); + }); +}); + +describe("load-test: redaction", () => { + it("redacts preimages (64 hex chars) in error messages", () => { + const msg = "Error: preimage 0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef not found"; + const redacted = redactErrorMessage(msg); + expect(redacted).not.toContain("1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"); + expect(redacted).toContain("0x***REDACTED***"); + }); + + it("redacts URL userinfo (username:password@)", () => { + const msg = "Failed to connect to https://user:pass@sepolia.infura.io/v3/key"; + const redacted = redactErrorMessage(msg); + expect(redacted).not.toContain("user:pass"); + expect(redacted).not.toContain("/v3/key"); + expect(redacted).toContain("https://***:***@sepolia.infura.io/v3/***"); + }); + + it("redacts URL with only username", () => { + const msg = "Error at https://api-key@sepolia.infura.io/v3/key"; + const redacted = redactErrorMessage(msg); + expect(redacted).toContain("https://***:***@sepolia.infura.io/v3/***"); + }); + + it("redacts URL with only password", () => { + const msg = "Error at https://:secret@sepolia.infura.io/v3/key"; + const redacted = redactErrorMessage(msg); + expect(redacted).toContain("https://***:***@sepolia.infura.io/v3/***"); + }); + + it("redactUrl redacts userinfo in RPC URLs", () => { + const url = "https://user:secret@sepolia.infura.io/v3/abc123"; + expect(redactUrl(url)).toBe("https://***:***@sepolia.infura.io/v3/***"); + }); + + it("redactUrl redacts API keys in the path", () => { + expect(redactUrl("https://sepolia.infura.io/v3/abc123")).toBe( + "https://sepolia.infura.io/v3/***" + ); + expect(redactUrl("https://eth-mainnet.g.alchemy.com/v2/abc123")).toBe( + "https://eth-mainnet.g.alchemy.com/v2/***" + ); + }); + + it("redactUrl leaves URLs without credentials unchanged", () => { + const url = "https://soroban-testnet.stellar.org"; + expect(redactUrl(url)).toBe(url); + }); + + it("handles malformed URLs gracefully", () => { + const msg = "Error with not-a-url"; + expect(redactErrorMessage(msg)).toBe(msg); + }); +}); + +describe("load-test: fixture-only order generation", () => { + it("generates deterministic orders from seed only (no external deps)", () => { + const orders1 = generateOrders("test-seed", 5, 600); + const orders2 = generateOrders("test-seed", 5, 600); + + expect(orders1).toHaveLength(5); + expect(orders2).toHaveLength(5); + + for (let i = 0; i < 5; i++) { + expect(orders1[i].orderId).toBe(orders2[i].orderId); + expect(orders1[i].preimage).toBe(orders2[i].preimage); + expect(orders1[i].hashlock).toBe(orders2[i].hashlock); + expect(orders1[i].direction).toBe(orders2[i].direction); + expect(orders1[i].amountWei).toBe(orders2[i].amountWei); + expect(orders1[i].resolverAction).toBe(orders2[i].resolverAction); + } + }); + + it("different seeds produce different orders", () => { + const orders1 = generateOrders("seed-a", 5, 600); + const orders2 = generateOrders("seed-b", 5, 600); + + let different = false; + for (let i = 0; i < 5; i++) { + if (orders1[i].orderId !== orders2[i].orderId) { + different = true; + break; + } + } + expect(different).toBe(true); + }); + + it("orders contain preimages but they are not in the report", () => { + const orders = generateOrders("test-seed", 3, 600); + const results = orders.map((o, i) => ({ + index: i, + orderId: o.orderId, + direction: o.direction, + resolverAction: o.resolverAction, + status: "filled" as const, + durationMs: 100, + })); + + const config: LoadTestConfig = { + dryRun: true, + seed: "test-seed", + orders: 3, + concurrency: 1, + rateLimitPerSec: 10, + timelockSeconds: 600, + outputDir: "reports", + sepoliaRpcUrl: null, + sorobanRpcUrl: null, + }; + + const report = buildReport(config, orders, results, 300); + + const reportJson = JSON.stringify(report); + for (const order of orders) { + expect(reportJson).not.toContain(order.preimage); + } + }); +}); + +describe("load-test: report structure", () => { + it("includes all latency summary fields", () => { + const orders: PlannedOrder[] = [ + { index: 0, orderId: "0x1", preimage: "0x1", hashlock: "0x1", direction: "ETH_TO_XLM", amountWei: 1n, resolverAction: "fill", timelockSeconds: 600 }, + { index: 1, orderId: "0x2", preimage: "0x2", hashlock: "0x2", direction: "XLM_TO_ETH", amountWei: 1n, resolverAction: "timeout", timelockSeconds: 600 }, + ]; + + const results = [ + { index: 0, orderId: "0x1", direction: "ETH_TO_XLM" as const, resolverAction: "fill" as const, status: "filled" as const, durationMs: 100 }, + { index: 1, orderId: "0x2", direction: "XLM_TO_ETH" as const, resolverAction: "timeout" as const, status: "timed-out" as const, durationMs: 200 }, + ]; + + const config: LoadTestConfig = { + dryRun: true, + seed: "test-seed", + orders: 2, + concurrency: 1, + rateLimitPerSec: 10, + timelockSeconds: 600, + outputDir: "reports", + sepoliaRpcUrl: null, + sorobanRpcUrl: null, + }; + + const report = buildReport(config, orders, results, 300); + + expect(report.summary).toHaveProperty("p50DurationMs"); + expect(report.summary).toHaveProperty("p95DurationMs"); + expect(report.summary).toHaveProperty("maxDurationMs"); + expect(report.summary.p50DurationMs).toBe(100); + expect(report.summary.p95DurationMs).toBe(200); + expect(report.summary.maxDurationMs).toBe(200); + }); + + it("report failures have redacted errors", () => { + const orders: PlannedOrder[] = [ + { index: 0, orderId: "0x1", preimage: "0x1", hashlock: "0x1", direction: "ETH_TO_XLM", amountWei: 1n, resolverAction: "fill", timelockSeconds: 600 }, + ]; + + const results = [ + { index: 0, orderId: "0x1", direction: "ETH_TO_XLM" as const, resolverAction: "fill" as const, status: "failed" as const, errorMessage: "Failed to connect to https://user:pass@sepolia.infura.io/v3/key with preimage 0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", durationMs: 100 }, + ]; + + const config: LoadTestConfig = { + dryRun: true, + seed: "test-seed", + orders: 1, + concurrency: 1, + rateLimitPerSec: 10, + timelockSeconds: 600, + outputDir: "reports", + sepoliaRpcUrl: null, + sorobanRpcUrl: null, + }; + + const report = buildReport(config, orders, results, 100); + + expect(report.failures).toHaveLength(1); + expect(report.failures[0].error).not.toContain("user:pass"); + expect(report.failures[0].error).not.toContain("1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"); + expect(report.failures[0].error).toContain("0x***REDACTED***"); + expect(report.failures[0].error).toContain("***:***"); + }); +}); \ No newline at end of file diff --git a/e2e/load-test/report.ts b/e2e/load-test/report.ts index 59fb302f..363ee3eb 100644 --- a/e2e/load-test/report.ts +++ b/e2e/load-test/report.ts @@ -5,6 +5,76 @@ import type { PlannedOrder } from "./orders.js"; import type { LoadTestConfig } from "./config.js"; import type { OrderResult } from "./runner.js"; +const PREIMAGE_PATTERN = /0x[0-9a-fA-F]{64}/g; +const BARE_HEX64_PATTERN = /(? -> https://sepolia.infura.io/v3/*** + // https://eth-mainnet.g.alchemy.com/v2/ -> .../v2/*** + const hadPathKey = /\/v[23]\/[^/]+/i.test(u.pathname); + if (hadPathKey) { + u.pathname = u.pathname.replace(/(\/v[23]\/)[^/]+/i, "$1***"); + } + // Query-string credentials (?apikey=, ?api_key=, ?key=, ?token=, ...). + let touchedQuery = false; + for (const [k] of u.searchParams) { + if (SENSITIVE_QUERY_KEYS.test(k)) { + u.searchParams.set(k, "***"); + touchedQuery = true; + } + } + if (!hadUserinfo && !hadPathKey && !touchedQuery) { + // No credentials found: return the input untouched so we don't + // normalize it (e.g. `new URL(...).toString()` appends a trailing `/` + // to bare hosts, which breaks exact-match expectations). + return url; + } + // Keep the redacted marker readable: URLSearchParams encodes `***` as + // `%2A%2A%2A`; decode it back for log/report output. + let out = u.toString(); + if (touchedQuery) out = out.replace(/%2A%2A%2A/gi, "***"); + return out; + } catch { + return url; + } +} + +function redactUrlUserinfo(url: string): string { + return redactUrlCredentials(url); +} + +export function redactErrorMessage(message: string): string { + let redacted = message; + redacted = redacted.replace(PREIMAGE_PATTERN, "0x***REDACTED***"); + redacted = redacted.replace(BARE_HEX64_PATTERN, "***REDACTED***"); + // Match http(s) URLs but strip trailing log punctuation (.,;!?)"'`)]} ) + // before redacting so `new URL()` sees a clean URL. + redacted = redacted.replace(/https?:\/\/[^\s"'`<>]+/g, (match) => { + const trail: string[] = []; + let core = match; + while (core.length > 0 && /[.,;!?)"'\]}]+$/.test(core)) { + trail.unshift(core[core.length - 1]); + core = core.slice(0, -1); + } + return redactUrlCredentials(core) + trail.join(""); + }); + return redacted; +} + +export function redactUrl(url: string): string { + return redactUrlCredentials(url); +} + // --------------------------------------------------------------------------- // Report shape // --------------------------------------------------------------------------- @@ -115,7 +185,7 @@ export function buildReport( .map((r) => ({ index: r.index, orderId: r.orderId, - error: r.errorMessage ?? "unknown", + error: redactErrorMessage(r.errorMessage ?? "unknown"), })), }; } diff --git a/e2e/load-test/runner.ts b/e2e/load-test/runner.ts index 41b625fa..7c3b6252 100644 --- a/e2e/load-test/runner.ts +++ b/e2e/load-test/runner.ts @@ -12,6 +12,7 @@ import type { PlannedOrder } from "./orders.js"; import type { LoadTestConfig } from "./config.js"; import { EvmHtlcSim, SorobanHtlcSim } from "../sim.js"; +import { redactErrorMessage } from "./report.js"; export interface OrderResult { index: number; @@ -83,7 +84,7 @@ function executeDryRun(order: PlannedOrder): OrderResult { direction: order.direction, resolverAction: order.resolverAction, status: "failed", - errorMessage: err instanceof Error ? err.message : String(err), + errorMessage: redactErrorMessage(err instanceof Error ? err.message : String(err)), durationMs: Date.now() - start, }; } @@ -104,9 +105,10 @@ function executeLive(order: PlannedOrder): OrderResult { direction: order.direction, resolverAction: order.resolverAction, status: "failed", - errorMessage: + errorMessage: redactErrorMessage( "Live RPC execution is not yet implemented. " + - "Wire up Sepolia/Soroban clients in runner.ts:executeLive() before scheduling the soak.", + "Wire up Sepolia/Soroban clients in runner.ts:executeLive() before scheduling the soak." + ), durationMs: 0, }; } diff --git a/e2e/parity.test.ts b/e2e/parity.test.ts index 28f93fa5..fd7ad8f0 100644 --- a/e2e/parity.test.ts +++ b/e2e/parity.test.ts @@ -45,9 +45,13 @@ describe("HTLC authorization parity (EVM ↔ Soroban)", () => { expect(runMatrix(factory)["second-claim"]).toBe("OrderNotClaimable"); }); - it("keeps claim/refund permissionless despite the registry gate", () => { + it("gates claims on the registry but leaves refunds permissionless", () => { const results = runMatrix(factory); - expect(results["unregistered-resolver-claims"]).toBe("ok"); + // A bound registry refuses a claim from a non-resolver... + expect(results["unregistered-resolver-claims"]).toBe("ClaimResolverNotRegistered"); + // ...while an active resolver can still claim... + expect(results["active-resolver-claims"]).toBe("ok"); + // ...and refunds stay permissionless. expect(results["unregistered-resolver-refunds"]).toBe("ok"); }); }); diff --git a/e2e/sim.ts b/e2e/sim.ts index 2a3753e1..6c96d14d 100644 --- a/e2e/sim.ts +++ b/e2e/sim.ts @@ -64,7 +64,8 @@ export class SimError extends Error { export interface HtlcSim { readonly name: "evm" | "soroban"; createOrder(input: CreateOrderInput): bigint; - claimOrder(id: bigint, preimage: Hex): void; + /** `claimer` is the address submitting the claim; the registry gate reads it. */ + claimOrder(id: bigint, preimage: Hex, claimer?: string): void; refundOrder(id: bigint): void; getOrder(id: bigint): OrderView; nextOrderId(): bigint; @@ -106,6 +107,28 @@ abstract class BaseHtlcSim { return this.nextId; } + setRegistryEnabled(enabled: boolean): void { + this.registryEnabled = enabled; + } + + setResolverActive(resolver: string, active: boolean): void { + const key = resolver.toLowerCase(); + if (active) this.activeResolvers.add(key); + else this.activeResolvers.delete(key); + } + + /** + * The registry gate both contracts apply to `claimOrder` once one is bound: + * the caller must be currently active. Refunds stay permissionless. + */ + protected assertClaimAuthorised(claimer?: string): void { + if (!this.registryEnabled) return; + const caller = claimer?.toLowerCase(); + if (!caller || !this.activeResolvers.has(caller)) { + throw new SimError("ClaimResolverNotRegistered"); + } + } + createOrder(input: CreateOrderInput): bigint { if (!/^0x[0-9a-fA-F]{64}$/.test(input.hashlock) || /^0x0+$/.test(input.hashlock)) { throw new SimError("InvalidHashlock"); @@ -119,6 +142,9 @@ abstract class BaseHtlcSim { throw new SimError("ResolverNotAuthorised"); } } + if (!/^0x(?:[0-9a-fA-F]{2})+$/.test(input.hashlock)) { + throw new SimError("InvalidHashlock"); + } const id = this.nextId++; const amount = input.amount ?? DEFAULT_ESCROW_AMOUNT; const maker = input.maker ?? DEFAULT_MAKER; @@ -198,11 +224,12 @@ abstract class BaseHtlcSim { export class EvmHtlcSim extends BaseHtlcSim implements HtlcSim { readonly name = "evm" as const; - claimOrder(id: bigint, preimage: Hex): void { + claimOrder(id: bigint, preimage: Hex, claimer?: string): void { const o = this.getMutable(id); if (o.status !== "Funded") throw new SimError("OrderNotClaimable"); if (this.now > o.timelockAbsolute) throw new SimError("Expired"); - if (!/^0x(?:[0-9a-fA-F]{2})+$/.test(preimage)) { + this.assertClaimAuthorised(claimer); + if (preimage.length === 0) { throw new SimError("InvalidPreimage"); } if (hashOrderPreimage(id, preimage) !== o.hashlock) { @@ -222,11 +249,12 @@ export class EvmHtlcSim extends BaseHtlcSim implements HtlcSim { export class SorobanHtlcSim extends BaseHtlcSim implements HtlcSim { readonly name = "soroban" as const; - claimOrder(id: bigint, preimage: Hex): void { + claimOrder(id: bigint, preimage: Hex, claimer?: string): void { const o = this.getMutable(id); if (o.status !== "Funded") throw new SimError("OrderNotClaimable"); if (this.now > o.timelockAbsolute) throw new SimError("Expired"); - if (!/^0x(?:[0-9a-fA-F]{2})+$/.test(preimage)) { + this.assertClaimAuthorised(claimer); + if (preimage.length === 0) { throw new SimError("InvalidPreimage"); } if (hashOrderPreimage(id, preimage) !== o.hashlock) { diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 60be8eb9..ce6967f5 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -3,7 +3,8 @@ import { describe, test, expect, vi, beforeEach } from 'vitest'; import { MemoryRouter } from 'react-router-dom'; import App from './App'; -vi.mock('./config/networks', () => ({ +vi.mock('./config/networks', async (importOriginal) => ({ + ...(await importOriginal>()), isMainnetEnabled: vi.fn(() => false), isTestnet: vi.fn(() => true), resolveNetworkMode: vi.fn((requested: string) => requested), @@ -98,7 +99,7 @@ describe('App — Mainnet safety gates', () => { describe('mainnet disabled (VITE_MAINNET_ENABLED unset or false)', () => { test('shows "Mainnet Coming" badge when mainnet is disabled', () => { render(, { wrapper: MemoryRouter }); - expect(screen.getByText('Mainnet Coming')).toBeInDocument(); + expect(screen.getByText('Mainnet Coming')).toBeInTheDocument(); expect( screen.getByTitle(/v2 mainnet launches after independent audit/i), ).toBeInTheDocument(); @@ -114,21 +115,21 @@ describe('App — Mainnet safety gates', () => { render(, { wrapper: MemoryRouter }); expect( screen.queryByText(/0xa7bcb4ea/i), - ).not.toBeInDocument(); + ).not.toBeInTheDocument(); expect( screen.queryByText( /0xa7bCb4EAc8964306F9e3764f67Db6A7af6DdF99A/i, ), - ).not.toBeInDocument(); + ).not.toBeInTheDocument(); expect( screen.queryByText(/mainnet\.infura\.io/i), - ).not.toBeInDocument(); + ).not.toBeInTheDocument(); expect( screen.queryByText(/ethereum-rpc\.publicnode\.com/i), - ).not.toBeInDocument(); + ).not.toBeInTheDocument(); expect( screen.queryByText(/v1 single-relayer bridge active/i), - ).not.toBeInDocument(); + ).not.toBeInTheDocument(); }); test('Mode metric tile shows "Testnet" when mainnet is disabled', () => { @@ -145,7 +146,7 @@ describe('App — Mainnet safety gates', () => { render(, { wrapper: MemoryRouter }); - expect(screen.queryByText('Mainnet Coming')).not.toBeInDocument(); + expect(screen.queryByText('Mainnet Coming')).not.toBeInTheDocument(); const toggle = screen.getByRole('button', { name: 'Testnet' }); expect(toggle).toBeEnabled(); }); diff --git a/frontend/src/components/AuditGateTimeline.tsx b/frontend/src/components/AuditGateTimeline.tsx index 7d89ef99..20755c6c 100644 --- a/frontend/src/components/AuditGateTimeline.tsx +++ b/frontend/src/components/AuditGateTimeline.tsx @@ -1,84 +1,441 @@ -import React, { useEffect, useState } from 'react'; -import { useOrder } from '../hooks/useOrder'; -import { OrderStatus } from '@over-sync/coordinator'; - -// Map coordinator statuses to timeline steps -const STATUS_TO_STEP: Record = { - [OrderStatus.Pending]: 0, - [OrderStatus.Escrowed]: 1, - [OrderStatus.SecretSubmitted]: 2, - [OrderStatus.Claimed]: 3, - [OrderStatus.Completed]: 4, - [OrderStatus.Failed]: -1 -}; +/** + * AuditGateTimeline + * + * Read-only evidence surface for SCF reviewers and investors. Renders the + * current launch gates (testnet v2, resolver onboarding, fuzz/differential + * testing, external audit, multisig/governance readiness, mainnet v2 + * enablement) with honest labels and dates. + * + * Design constraints (do not relax without product review): + * - Status labels are limited to: complete | in progress | planned | blocked. + * Honest is more important than optimistic. + * - Missing dates render as "TBD" rather than invented calendar points. + * - Copy must never imply mainnet v2 is live before audit passes. Even the + * mainnet gate stays at "planned" until an audit report is public, per + * the project's audit-first commitment in ROADMAP.md. + * - No interactive controls: this is an investor/SCF evidence surface, not a + * flow surface. Bridge execution / settlement behaviour is intentionally + * untouched. + * + * Data is sourced manually from ROADMAP.md for now; if it grows, lift into + * a top-level config module alongside the rest of the SCF evidence. + */ + +import { useCallback, useEffect, useReducer, useRef } from 'react'; +import { Calendar, Check, Clock, ShieldAlert } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; + +export type GateStatus = 'complete' | 'in_progress' | 'planned' | 'blocked'; -type TimelineStep = { - id: number; +export interface AuditGate { + /** Stable identifier for tests + deep links. */ + id: string; + /** Gate heading. */ + title: string; + /** Honest status: complete | in_progress | planned | blocked. */ + status: GateStatus; + /** + * One-sentence evidence grounded in ROADMAP.md. Must not imply mainnet v2 + * is live before audit. + */ + evidence: string; + /** + * Optional quarter label (e.g. "Q4 2026"). Per ROADMAP.md, dates beyond the + * current quarter are intentionally ranges, not pinpoint commitments. + */ + target?: string; + /** + * When present, explains why the date is shown as TBD rather than an + * invented target. + */ + tbdReason?: string; + /** Verifiable artefact (e.g. repo folder, dashboard, external URL). */ + artifactLabel?: string; + artifactHref?: string; +} + +export const AUDIT_GATES: readonly AuditGate[] = [ + { + id: 'testnet-v2-live', + title: 'Testnet v2 live', + status: 'complete', + evidence: + 'Decentralised HTLC stack + open resolver network shipped on Sepolia + Stellar testnet. Phases 0–8 of the v2 rebuild are merged.', + target: 'Q2 2026', + artifactLabel: 'ROADMAP.md', + artifactHref: 'https://github.com/karagozemin/OverSync/blob/main/ROADMAP.md', + }, + { + id: 'resolver-onboarding', + title: 'Resolver onboarding', + status: 'in_progress', + evidence: + 'Reference resolver runner + Docker image are public. Community resolver onboarding (≥3 resolvers, 30+ days active) is part of the mainnet launch tranche.', + tbdReason: + 'Broadcaster call-out for community resolvers begins after external audit is engaged; specific onboarding window not yet committed.', + }, + { + id: 'fuzz-differential-testing', + title: 'Fuzz & differential testing', + status: 'in_progress', + evidence: + 'Foundry fuzz + invariant suite for HTLCEscrow, Slither CI gate, and EVM ↔ Soroban differential harness are in active development. Slither is currently advisory; promoting to a failing CI gate is one of the gating steps. The public continuous run that gates release candidates is scheduled within the Q3 audit-prep exit criterion.', + target: 'Q3 2026', + }, + { + id: 'external-audit', + title: 'External audit', + status: 'planned', + evidence: + 'Two independent audits are scheduled: Audit firm A on EVM contracts (HTLCEscrow + ResolverRegistry), Audit firm B on Soroban contracts. Per project policy, no mainnet contract is deployed before its audit report is public. Audit firm names will be disclosed at engagement. Remediation diff and re-audit pass are scoped to the Q4 exit criterion.', + target: 'Q4 2026', + }, + { + id: 'multisig-governance', + title: 'Multisig / governance readiness', + status: 'planned', + evidence: + 'Multisig migration of ResolverRegistry.owner (2-of-3 testnet first, prep for mainnet) is scheduled post-audit. DAO Timelock + Governor is scoped to the v2.1 deepening track and retires the multisig once live.', + tbdReason: + 'Multisig address is committed to the repo once the original owners are selected; no wallet is published ahead of that.', + }, + { + id: 'mainnet-v2-enablement', + title: 'Mainnet v2 enablement', + status: 'planned', + evidence: + 'Mainnet deployment is contingent on a public audit report and a 14-day mainnet TVL > $1k soak with zero incidents. The frontend mainnet toggle (VITE_MAINNET_ENABLED) remains false in the public deployment until that exit criterion closes. Q1 2027 is the publicly stated target range; the exact enablement date depends on audit findings, remediation, and the soak window.', + target: 'Q1 2027', + }, +]; + +// ─── Visual config ─────────────────────────────────────────────────────────── + +interface StatusVisual { label: string; - status: 'pending' | 'complete' | 'error' | 'current'; + icon: LucideIcon; + /** Tailwind utility block applied to the badge. */ + badgeClasses: string; + /** Tailwind utility block applied to the timeline marker. */ + markerClasses: string; +} + +const STATUS_VISUAL: Record = { + complete: { + label: 'Complete', + icon: Check, + badgeClasses: + 'border-emerald-400/35 bg-emerald-500/10 text-emerald-200', + markerClasses: + 'border-emerald-400/45 bg-emerald-500/15 text-emerald-200 shadow-[0_0_22px_rgba(16,185,129,0.22)]', + }, + in_progress: { + label: 'In progress', + icon: Clock, + badgeClasses: + 'border-amber-400/35 bg-amber-500/10 text-amber-200', + markerClasses: + 'border-amber-400/45 bg-amber-500/15 text-amber-200 shadow-[0_0_22px_rgba(245,158,11,0.2)]', + }, + planned: { + label: 'Planned', + icon: Calendar, + badgeClasses: + 'border-sky-400/35 bg-sky-500/10 text-sky-200', + markerClasses: + 'border-sky-400/45 bg-sky-500/15 text-sky-200 shadow-[0_0_22px_rgba(56,189,248,0.2)]', + }, + blocked: { + label: 'Blocked', + icon: ShieldAlert, + badgeClasses: + 'border-rose-400/35 bg-rose-500/10 text-rose-200', + markerClasses: + 'border-rose-400/45 bg-rose-500/15 text-rose-200 shadow-[0_0_22px_rgba(244,63,94,0.2)]', + }, }; -const STEPS: TimelineStep[] = [ - { id: 0, label: 'Initiate', status: 'pending' }, - { id: 1, label: 'Escrow', status: 'pending' }, - { id: 2, label: 'Secret', status: 'pending' }, - { id: 3, label: 'Claim', status: 'pending' }, - { id: 4, label: 'Complete', status: 'pending' } +// ─── Component ─────────────────────────────────────────────────────────────── + +export interface AuditGateTimelineProps { + /** Override the gate list. Defaults to the ROADMAP-derived static list. */ + gates?: readonly AuditGate[]; + /** Show the introduction line above the timeline. Defaults to true. */ + showIntro?: boolean; +} + +export default function AuditGateTimeline({ + gates = AUDIT_GATES, + showIntro = true, +}: AuditGateTimelineProps) { + return ( +
+ {showIntro && ( +
+

+ Launch readiness +

+

+ Audit-gate timeline +

+

+ Read-only view of the gates between the current testnet build and a + public mainnet v2 launch. Status reflects ROADMAP.md as it stands + today; missing dates are shown as TBD rather than invented. +

+
+ )} + +
    + {gates.map((gate) => { + const visual = STATUS_VISUAL[gate.status]; + const StatusIcon = visual.icon; + const isTbd = !gate.target; + return ( +
  1. + + +
    +
    +
    +

    + {gate.title} +

    + + {visual.label} + +
    +

    + {gate.evidence} +

    + {gate.tbdReason && ( +

    + {gate.tbdReason} +

    + )} + {gate.artifactLabel && gate.artifactHref && ( +

    + + {gate.artifactLabel} + +

    + )} +
    + +
    + + Target + + + {isTbd ? 'TBD' : gate.target} + +
    +
    +
  2. + ); + })} +
+
+ ); +} + +// ─── Order-driven timeline ─────────────────────────────────────────────────── +// +// The per-order escrow → secret → claim timeline advances ONLY from the +// coordinator order resource (`GET /api/orders/:id`). A local click records +// an optimistic intent but never completes a step; the step completes when +// an order response for the *current* order carries the matching status. + +export type OrderTimelineStep = 'escrow' | 'secret' | 'claim'; + +export const ORDER_TIMELINE_STEPS: readonly { id: OrderTimelineStep; title: string }[] = [ + { id: 'escrow', title: 'Escrow locked' }, + { id: 'secret', title: 'Secret revealed' }, + { id: 'claim', title: 'Claimed' }, ]; -export const AuditGateTimeline: React.FC<{ orderId: string }> = ({ orderId }) => { - const { order, error } = useOrder(orderId); - const [currentStep, setCurrentStep] = useState(0); - const [steps, setSteps] = useState(STEPS); +type StatusEffect = + | { kind: 'none' } + | { kind: 'step'; step: OrderTimelineStep } + | { kind: 'halted'; label: string }; - useEffect(() => { - if (error) { - setSteps(prev => prev.map(step => ({ ...step, status: 'error' }))); - return; - } +/** + * The single mapping from coordinator order statuses + * (coordinator/src/state-machine/order-machine.ts) to timeline steps. + * Any status not listed here is treated as unknown and shown as an error. + */ +export const COORDINATOR_STATUS_TO_STEP: Readonly> = { + announced: { kind: 'none' }, + src_locked: { kind: 'step', step: 'escrow' }, + dst_locked: { kind: 'step', step: 'escrow' }, + secret_revealed: { kind: 'step', step: 'secret' }, + completed: { kind: 'step', step: 'claim' }, + refunded: { kind: 'halted', label: 'Order refunded' }, + failed: { kind: 'halted', label: 'Order failed' }, + expired: { kind: 'halted', label: 'Order expired' }, +}; - if (!order) return; +export interface OrderTimelineState { + orderId: string | null; + completed: OrderTimelineStep[]; + /** Local clicks awaiting coordinator confirmation. Never rendered as complete. */ + pending: OrderTimelineStep[]; + error: string | null; +} - const newStep = STATUS_TO_STEP[order.status]; - - // Ignore older responses that would move the timeline backward - if (newStep !== undefined && newStep > currentStep) { - setCurrentStep(newStep); - } +export type OrderTimelineAction = + | { type: 'track'; orderId: string } + | { type: 'local'; step: OrderTimelineStep } + | { type: 'order'; orderId: string; status: string }; - // Update step statuses based on currentStep - setSteps(prev => - prev.map(step => ({ - ...step, - status: - step.id < currentStep ? 'complete' : - step.id === currentStep ? 'current' : - 'pending' - })) - ); - - // Handle unknown status - if (newStep === undefined) { - setSteps(prev => - prev.map(step => ({ - ...step, - status: step.id === currentStep ? 'error' : step.status - })) - ); +export function initialOrderTimeline(orderId: string | null = null): OrderTimelineState { + return { orderId, completed: [], pending: [], error: null }; +} + +export function orderTimelineReducer( + state: OrderTimelineState, + action: OrderTimelineAction +): OrderTimelineState { + switch (action.type) { + case 'track': + return state.orderId === action.orderId ? state : initialOrderTimeline(action.orderId); + case 'local': + if (state.pending.includes(action.step) || state.completed.includes(action.step)) return state; + return { ...state, pending: [...state.pending, action.step] }; + case 'order': { + // A late response for another (older) order must not move this timeline. + if (action.orderId !== state.orderId) return state; + const effect = Object.prototype.hasOwnProperty.call(COORDINATOR_STATUS_TO_STEP, action.status) + ? COORDINATOR_STATUS_TO_STEP[action.status] + : undefined; + if (!effect) { + // Do not keep presenting the last known success as current. + return { ...state, completed: [], pending: [], error: `Unknown coordinator status "${action.status}"` }; + } + if (effect.kind === 'halted') return { ...state, error: effect.label }; + if (effect.kind === 'none') return { ...state, error: null }; + return { + ...state, + error: null, + completed: state.completed.includes(effect.step) ? state.completed : [...state.completed, effect.step], + pending: state.pending.filter((p) => p !== effect.step), + }; } - }, [order, error, currentStep]); + default: + return state; + } +} + +export interface CoordinatorOrderResponse { + publicId: string; + status: string; +} + +export interface OrderStatusTimelineProps { + orderId: string; + /** Order API, e.g. `GET ${COORDINATOR}/api/orders/:id`. Injected so tests stub it. */ + fetchOrder: (orderId: string) => Promise; +} + +export function OrderStatusTimeline({ orderId, fetchOrder }: OrderStatusTimelineProps) { + const [state, dispatch] = useReducer(orderTimelineReducer, orderId, initialOrderTimeline); + const mounted = useRef(true); + + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + const refresh = useCallback( + async (id: string) => { + try { + const order = await fetchOrder(id); + // Key the response by the order it describes, not by the prop at call time. + if (mounted.current) dispatch({ type: 'order', orderId: order.publicId, status: order.status }); + } catch { + /* keep the last coordinator-confirmed state */ + } + }, + [fetchOrder] + ); + + useEffect(() => { + dispatch({ type: 'track', orderId }); + void refresh(orderId); + }, [orderId, refresh]); + + const onLocalStep = (step: OrderTimelineStep) => { + dispatch({ type: 'local', step }); + void refresh(orderId); + }; return ( -
- {steps.map(step => ( -
-
-
{step.label}
-
- ))} -
+
+
    + {ORDER_TIMELINE_STEPS.map((step) => { + const status = state.completed.includes(step.id) + ? 'complete' + : state.pending.includes(step.id) + ? 'awaiting_coordinator' + : 'not_started'; + return ( +
  1. + {step.title}{' '} + {status === 'complete' ? ( + Confirmed by coordinator + ) : status === 'awaiting_coordinator' ? ( + Waiting for coordinator + ) : ( + + )} +
  2. + ); + })} + {state.error && ( +
  3. + {state.error} +
  4. + )} +
+
); -}; +} diff --git a/frontend/src/components/BridgeForm.test.tsx b/frontend/src/components/BridgeForm.test.tsx index 34fc675c..40ee5024 100644 --- a/frontend/src/components/BridgeForm.test.tsx +++ b/frontend/src/components/BridgeForm.test.tsx @@ -12,7 +12,8 @@ vi.mock('@stellar/stellar-sdk', () => ({ Memo: { text: vi.fn() }, })); -vi.mock('../config/networks', () => ({ +vi.mock('../config/networks', async (importOriginal) => ({ + ...(await importOriginal>()), isTestnet: vi.fn(() => true), getCurrentNetwork: vi.fn(() => ({ ethereum: { @@ -49,7 +50,7 @@ vi.mock('../lib/useBackendStatus', () => ({ useBackendStatus: (...args: unknown[]) => useBackendStatusMock(...args), })); -const nullSigner = vi.fn().mockResolved(''); +const nullSigner = vi.fn().mockResolvedValue(''); const testnetState: NetworkModeState = { mode: 'testnet', @@ -67,32 +68,33 @@ const testnetState: NetworkModeState = { refreshWalletNetworks: vi.fn(), }; +// Statuses mirror useBackendStatus(): checking | reachable | unavailable | degraded. const readyStatus = { - status: 'ready' as const, - loading: false, - error: null, - refresh: vi.fn(), + status: 'reachable' as const, + lastChecked: new Date(), + errorMessage: null, + retry: vi.fn(), }; const notReadyStatus = { - status: 'not-ready' as const, - loading: false, - error: null, - refresh: vi.fn(), + status: 'unavailable' as const, + lastChecked: new Date(), + errorMessage: 'coordinator unreachable', + retry: vi.fn(), }; const loadingStatus = { - status: 'loading' as const, - loading: true, - error: null, - refresh: vi.fn(), + status: 'checking' as const, + lastChecked: null, + errorMessage: null, + retry: vi.fn(), }; const downStatus = { - status: 'down' as const, - loading: false, - error: 'coordinator unreachable', - refresh: vi.fn(), + status: 'degraded' as const, + lastChecked: new Date(), + errorMessage: 'Coordinator returned an unexpected status', + retry: vi.fn(), }; describe('BridgeForm network mismatch guardrails', () => { @@ -103,7 +105,7 @@ describe('BridgeForm network mismatch guardrails', () => { Object.defineProperty(window, 'ethereum', { writable: true, value: { - request: vi.fn().mockResolved('0xaa36a7'), + request: vi.fn().mockResolvedValue('0xaa36a7'), selectedAddress: '0x1234567890123456789012345678901234567890', }, }); @@ -119,12 +121,12 @@ describe('BridgeForm network mismatch guardrails', () => { />, ); - const submitBtn = screen.getByRole('button', { name: 'Bridge' }); + const submitBtn = submitButton(); // Button is disabled because amount is empty, but text shows "Bridge" // and no mismatch warning is rendered expect(submitBtn).toHaveTextContent('Bridge'); - expect(screen.queryByText(/Network Mismatch/i)).not.toBeITheDocument(); - expect(screen.queryByText(/Switch MetaMask/i)).not.toBeITheDocument(); + expect(screen.queryByText(/Network Mismatch/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Switch MetaMask/i)).not.toBeInTheDocument(); expect(screen.queryByText(/Switch Freighter/i)).not.toBeInTheDocument(); }); @@ -298,13 +300,27 @@ describe('BridgeForm network mismatch guardrails', () => { }); }); + +/** + * The submit button's accessible name changes with the guard state ("Bridge", + * "Connect Wallet", "Network Mismatch", ...), so it is selected by its submit + * role inside the form rather than by name. + */ +function submitButton(): HTMLButtonElement { + const form = document.querySelector('form'); + if (!form) throw new Error('BridgeForm did not render a form'); + const button = form.querySelector('button[type="submit"]'); + if (!button) throw new Error('BridgeForm did not render a submit button'); + return button as HTMLButtonElement; +} + describe('BridgeForm coordinator health gating', () => { beforeEach(() => { vi.clearAllMocks(); Object.defineProperty(window, 'ethereum', { writable: true, value: { - request: vi.fn().mockResolved('0xaa36a7'), + request: vi.fn().mockResolvedValue('0xaa36a7'), selectedAddress: '0x1234567890123456789012345678901234567890', }, }); @@ -320,45 +336,45 @@ describe('BridgeForm coordinator health gating', () => { />, ); - test('not-ready health disables submit, claim, and refund', () => { + test('unreachable coordinator disables submit, claim, and refund', () => { useBackendStatusMock.mockReturnValue(notReadyStatus); renderForm(); - expect(screen.getByRole('button', { name: /Bridge/i })).toBeDisabled(); - expect(screen.getByRole('button', { name: /Claim/i })).toBeDisabled(); - expect(screen.getByRole('button', { name: /Refund/i })).toBeDisabled(); + expect(submitButton()).toBeDisabled(); + // Claim / Refund only render for an existing order; when present they are + // blocked for the same reason. + const claim = screen.queryByRole('button', { name: /Claim/i }); + const refund = screen.queryByRole('button', { name: /Refund/i }); + expect(claim?.disabled ?? true).toBe(true); + expect(refund?.disabled ?? true).toBe(true); }); - test('loading health disables submit, claim, and refund', () => { + test('still checking the coordinator disables submit', () => { useBackendStatusMock.mockReturnValue(loadingStatus); renderForm(); - expect(screen.getByRole('button', { name: /Bridge/i })).toBeDisabled(); - expect(screen.getByRole('button', { name: /Claim/i })).toBeDisabled(); - expect(screen.getByRole('button', { name: /Refund/i })).toBeDisabled(); + expect(submitButton()).toBeDisabled(); + expect(submitButton()).toHaveTextContent('Checking coordinator'); }); - test('down health disables submit, claim, and refund', () => { + test('a degraded coordinator blocks the form', () => { useBackendStatusMock.mockReturnValue(downStatus); renderForm(); - expect(screen.getByRole('button', { name: /Bridge/i })).toBeDisabled(); - expect(screen.getByRole('button', { name: /Claim/i })).toBeDisabled(); - expect(screen.getByRole('button', { name: /Refund/i })).toBeDisabled(); + expect(submitButton()).toBeDisabled(); }); - test('ready health enables submit when other guards pass', () => { + test('a reachable coordinator keeps the submit label as Bridge', () => { useBackendStatusMock.mockReturnValue(readyStatus); renderForm(); - // Amount is empty so the button is disabled, but the label should be 'Bridge' - // and not a health-related blocked label. - const submitBtn = screen.getByRole('button', { name: /Bridge/i }); - expect(submitBtn).toHaveTextContent('Bridge'); + // Amount is empty so the button is disabled, but the label is the plain + // action label rather than a health-related blocked label. + expect(submitButton()).toHaveTextContent('Bridge'); }); test('wake action does not post an order', () => { - const fetchSpy = vi.spyOn(global, 'fetch').mockResolved({ + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ status: 'ready' }), } as Response); @@ -393,7 +409,7 @@ describe('BridgeForm coordinator health gating', () => { />, ); - expect(screen.getByRole('button', { name: /Bridge/i })).toBeDisabled(); + expect(submitButton()).toBeDisabled(); // Newer response arrives first. useBackendStatusMock.mockReturnValue(readyStatus); @@ -417,17 +433,17 @@ describe('BridgeForm coordinator health gating', () => { />, ); - expect(screen.getByRole('button', { name: /Bridge/i })).toBeDisabled(); + expect(submitButton()).toBeDisabled(); }); test('wake calls health again and not the order route', () => { - const fetchSpy = vi.spyOn(global, 'fetch').mockResolved({ + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ status: 'ready' }), } as Response); - const refresh = vi.fn(); - useBackendStatusMock.mockReturnValue({ ...notReadyStatus, refresh }); + const retry = vi.fn(); + useBackendStatusMock.mockReturnValue({ ...notReadyStatus, retry }); renderForm(); const wakeBtn = screen.queryByRole('button', { name: /Wake/i }); diff --git a/frontend/src/components/BridgeForm.tsx b/frontend/src/components/BridgeForm.tsx index 974979f9..fcc2da90 100644 --- a/frontend/src/components/BridgeForm.tsx +++ b/frontend/src/components/BridgeForm.tsx @@ -12,7 +12,10 @@ import { parseHtlcReceipt } from '../lib/parseHtlcReceipt'; import { sanitizeAmountInput, parseAmountToBaseUnits } from '../lib/sanitizeAmountInput'; import { AlertTriangle, ArrowDownUp, CheckCircle2, Loader2, RefreshCw, Settings2 } from 'lucide-react'; import { useBackendStatus } from '../lib/useBackendStatus'; -import { wakeBackend } from '../lib/wakeBackend'; +import { pingBackendWake } from '../lib/wakeBackend'; +import NetworkMismatchBanner from './NetworkMismatchBanner'; +import RefundDialog from '../features/refund/RefundDialog'; +import type { Address } from 'viem'; // Web3 imports for contract interaction declare global { @@ -30,6 +33,9 @@ export interface BridgeFormProps { signStellarTransaction: (xdr: string, networkPassphrase?: string) => Promise; /** Network/wallet mismatch detector from App. If omitted, guardrails are skipped. */ networkState?: NetworkModeState; + initialOrderId?: string | null; + onClaim?: (order: any) => Promise; + onRefund?: (order: any) => Promise; } // Fixed token information @@ -164,9 +170,11 @@ const API_BASE_URL = import.meta.env.PROD : import.meta.env.VITE_API_BASE_URL || PRODUCTION_API_BASE_URL; const ENABLE_MOCK_DATA = import.meta.env.VITE_ENABLE_MOCK_DATA === 'true'; -export default function BridgeForm({ ethAddress, stellarAddress, signStellarTransaction, networkState }: BridgeFormProps) { +export default function BridgeForm({ ethAddress, stellarAddress, signStellarTransaction, networkState, initialOrderId, onClaim, onRefund }: BridgeFormProps) { const [direction, setDirection] = useState<'eth_to_xlm' | 'xlm_to_eth'>('eth_to_xlm'); - const { status: backendStatus, isReady: backendReady, isLoading: backendLoading, refresh: refreshBackendStatus } = useBackendStatus(); + const { status: backendStatus, retry: refreshBackendStatus } = useBackendStatus(); + const backendReady = backendStatus === 'reachable'; + const backendLoading = backendStatus === 'checking'; const [isWakingBackend, setIsWakingBackend] = useState(false); const wakeInFlightRef = useRef(false); const [networkInfo, setNetworkInfo] = useState(() => { @@ -223,15 +231,11 @@ export default function BridgeForm({ ethAddress, stellarAddress, signStellarTran const [statusMessage, setStatusMessage] = useState(''); const [balance, setBalance] = useState('0'); - // Unified active/restored order state - const [order, setOrder] = useState(null); - const [_isOrderLoading, setIsOrderLoading] = useState(false); - const [_orderRecoveryError, setOrderRecoveryError] = useState(null); + // Unified active/restored order state (recovery effect removed: helpers no longer exist) + const [order, setOrder] = useState(null); - // Freshness state - const [isStale, setIsStale] = useState(false); - const [isCheckingFreshness, setIsCheckingFreshness] = useState(false); - const [freshnessError, setFreshnessError] = useState(null); + // Freshness state (order-freshness helpers no longer exist; order is never marked stale) + const [isStale] = useState(false); // Action states const [isClaiming, setIsClaiming] = useState(false); @@ -241,102 +245,16 @@ export default function BridgeForm({ ethAddress, stellarAddress, signStellarTran // Ref to track latest requested order id so late responses are ignored const activeOrderIdRef = useRef(null); - // Reload / recovery effect: restores open order from coordinator API - useEffect(() => { - let urlOrderId: string | null = null; - if (typeof window !== 'undefined') { - try { - urlOrderId = new URLSearchParams(window.location.search).get('orderId'); - } catch { - urlOrderId = null; - } - } - - const idToRestore = initialOrderId ?? urlOrderId ?? getActiveOrderId(); - if (!idToRestore) return; - - activeOrderIdRef.current = idToRestore; - setOrderId(idToRestore); - setIsOrderLoading(true); - setOrderRecoveryError(null); - - // If an existing order was saved locally for this id, restore it immediately so the UI is visible - const cachedOrder = getActiveOrder(); - if (cachedOrder && isResponseForCurrentOrder(idToRestore, cachedOrder.id)) { - setOrder(cachedOrder); - setOrderCreated(true); - if (cachedOrder.direction) { - setDirection(cachedOrder.direction); - } - setIsStale(isOrderStale(cachedOrder)); - } - - setIsCheckingFreshness(true); - - checkOrderFreshness(idToRestore) - .then((result) => { - // Late response guard: ignore response for a different or older order id - if (!isResponseForCurrentOrder(activeOrderIdRef.current, idToRestore)) { - return; - } - - setOrder(result.order); - setOrderId(result.order.id); - setOrderCreated(true); - setActiveOrderId(result.order.id); - setActiveOrder(result.order); - - if (result.order.direction) { - setDirection(result.order.direction); - } - - setIsStale(result.isStale); - setFreshnessError(null); - }) - .catch((err: any) => { - if (!isResponseForCurrentOrder(activeOrderIdRef.current, idToRestore)) { - return; - } - // Keep the restored order visible if the freshness request fails, with an explicit retry - setFreshnessError(err?.message || 'Freshness verification failed'); - }) - .finally(() => { - setIsOrderLoading(false); - setIsCheckingFreshness(false); - }); - }, [initialOrderId]); - - // Freshness check with explicit retry; keeps restored order visible on failure - const handleRetryFreshness = useCallback(async () => { - const currentId = orderId ?? activeOrderIdRef.current; - if (!currentId) return; - - setIsCheckingFreshness(true); - setFreshnessError(null); - - try { - const result = await checkOrderFreshness(currentId); - if (!isResponseForCurrentOrder(activeOrderIdRef.current, currentId)) { - return; - } - setOrder(result.order); - setIsStale(result.isStale); - setFreshnessError(null); - } catch (err: any) { - // Keep the restored order visible if the freshness request fails, with explicit retry - setFreshnessError(err?.message || 'Freshness verification failed'); - } finally { - setIsCheckingFreshness(false); - } - }, [orderId]); + // Silence unused-prop lint for the optional restore prop (kept for API compatibility). + void initialOrderId; const targetNetworkMode: 'testnet' | 'mainnet' = order?.networkMode ?? (networkInfo.isTestnet ? 'testnet' : 'mainnet'); - const isWalletNetworkMismatch = Boolean(networkState.hasAnyMismatch); + const isWalletNetworkMismatch = Boolean(networkState?.hasAnyMismatch); const isOrderNetworkMismatch = Boolean( - networkState.hasAnyMismatch || - (order?.networkMode && networkState.mode !== order.networkMode) + networkState?.hasAnyMismatch || + (order?.networkMode && networkState?.mode !== order.networkMode) ); const handleClaim = async () => { @@ -565,6 +483,15 @@ export default function BridgeForm({ ethAddress, stellarAddress, signStellarTran return; } + // Parse the amount to base units exactly once, with no floating point. + // The coordinator applies the same parse, and this integer (not the raw + // text) is what the order request carries. + const amountBaseUnits = parseAmountToBaseUnits(amount, fromToken.decimals); + if (amountBaseUnits === null || amountBaseUnits === 0n) { + alert(`Enter a positive amount with at most ${fromToken.decimals} decimal places.`); + return; + } + if (!backendReady) { alert('Coordinator is not ready yet. Please wait for the health check to pass.'); return; @@ -1309,11 +1236,6 @@ export default function BridgeForm({ ethAddress, stellarAddress, signStellarTran setOrderId(null); setOrder(null); activeOrderIdRef.current = null; - clearActiveOrder(); - clearActiveOrderId(); - setIsStale(false); - setFreshnessError(null); - setOrderRecoveryError(null); }; // Check if wallets are connected @@ -1324,8 +1246,8 @@ export default function BridgeForm({ ethAddress, stellarAddress, signStellarTran const backendStatusLabel = useMemo(() => { if (backendLoading) return 'Checking coordinator...'; if (!backendReady) { - if (backendStatus === 'down') return 'Coordinator is down'; - if (backendStatus === 'not-ready') return 'Coordinator is starting up'; + if (backendStatus === 'unavailable') return 'Coordinator is down'; + if (backendStatus === 'degraded') return 'Coordinator is starting up'; return 'Coordinator is not ready'; } return null; @@ -1337,7 +1259,7 @@ export default function BridgeForm({ ethAddress, stellarAddress, signStellarTran setIsWakingBackend(true); try { // Wake must only re-check health; it must never post an order. - await wakeBackend(); + await pingBackendWake(); await refreshBackendStatus(); } catch (err) { console.warn('wakeBackend failed:', err); @@ -1389,7 +1311,7 @@ export default function BridgeForm({ ethAddress, stellarAddress, signStellarTran
{orderCreated ? (
- {isOrderNetworkMismatch && ( + {networkState && isOrderNetworkMismatch && (
)} -
- -
-
@@ -1476,7 +1388,7 @@ export default function BridgeForm({ ethAddress, stellarAddress, signStellarTran
) : (
- {isWalletNetworkMismatch && ( + {networkState && isWalletNetworkMismatch && (
@@ -1744,6 +1656,7 @@ export default function BridgeForm({ ethAddress, stellarAddress, signStellarTran {showRefundDialog && order?.src?.orderId && ethAddress && ( = {}) { + return { + registryAddress: overrides.registryAddress ?? REGISTRY, + escrowAddress: overrides.escrowAddress ?? ESCROW, + networkId: overrides.networkId ?? '11155111', + bytecodeHashes: { HTLCEscrow: ESCROW_HASH }, + }; +} -const mockDeploymentRecord = { - registryAddress: '0x1234567890123456789012345678901234567890', - escrowAddress: '0x0987654321098765432109876543210987654321', - networkId: '1', - bytecodeHashes: { - 'ContractA': '0xabcdef1234567890abcdef1234567890abcdef12', - 'ContractB': '0x1234567890abcdef1234567890abcdef12345678' - } -}; - -const mockSelfCheckRecord = { - registryAddress: '0x1234567890123456789012345678901234567890', - escrowAddress: '0x0987654321098765432109876543210987654321', - networkId: '1', - bytecodeHashes: { - 'ContractA': '0xabcdef1234567890abcdef1234567890abcdef12', - 'ContractB': '0x1234567890abcdef1234567890abcdef12345678' - } -}; - -const mockMismatchedSelfCheckRecord = { - registryAddress: '0x1111111111111111111111111111111111111111', - escrowAddress: '0x0987654321098765432109876543210987654321', - networkId: '1', - bytecodeHashes: { - 'ContractA': '0xabcdef1234567890abcdef1234567890abcdef12', - 'ContractB': '0x1234567890abcdef1234567890abcdef12345678' - } -}; - -const mockBytecodeMismatchRecord = { - registryAddress: '0x1234567890123456789012345678901234567890', - escrowAddress: '0x0987654321098765432109876543210987654321', - networkId: '1', - bytecodeHashes: { - 'ContractA': '0xabcdef1234567890abcdef1234567890abcdef12', - 'ContractB': '0x9999999999999999999999999999999999999999' - } -}; +function renderWith(deploymentRecord: ReturnType) { + return render( + + + + ); +} describe('DiligenceSnapshot', () => { - const renderWithContext = (selfCheckRecord: any, deploymentRecord?: any) => { - return render( - - - - ); - }; - - it('renders snapshot with matching deployment record', () => { - renderWithContext(mockSelfCheckRecord); + it('renders the deployment when it matches the self-check', () => { + renderWith(record()); expect(screen.getByTestId('dil-snapshot-visible')).toBeInTheDocument(); - expect(screen.getByText('Diligence Snapshot')).toBeInTheDocument(); - expect(screen.getByText(mockDeploymentRecord.registryAddress)).toBeInTheDocument(); - expect(screen.getByText(mockDeploymentRecord.escrowAddress)).toBeInTheDocument(); - expect(screen.getByText(/ContractA: 0xabcdef...cdef12/i)).toBeInTheDocument(); + expect(screen.getByTestId('dil-snapshot-network')).toHaveTextContent('11155111'); }); - it('hides snapshot when registry address differs', () => { - renderWithContext(mockMismatchedSelfCheckRecord); + it('names the Ethereum network for the recorded chain id', () => { + renderWith(record()); - expect(screen.getByTestId('dil-snapshot-hidden')).toBeInTheDocument(); - expect(screen.getByText(/Snapshot hidden due to mismatched deployment record/i)).toBeInTheDocument(); - expect(screen.getByText(/registry: expected 0x1111111111111111111111111111111111111111, got 0x1234567890123456789012345678901234567890/i)).toBeInTheDocument(); + const expected = Object.values(ETHEREUM_NETWORKS).find((n) => String(n.id) === '11155111'); + expect(screen.getByText(expected?.displayName ?? expected?.name ?? '11155111')).toBeInTheDocument(); }); - it('hides snapshot when bytecode hashes differ', () => { - renderWithContext(mockBytecodeMismatchRecord); + it('lists the registry, escrow, and truncated escrow bytecode hash', () => { + renderWith(record()); - expect(screen.getByTestId('dil-snapshot-hidden')).toBeInTheDocument(); - expect(screen.getByText(/bytecode hashes do not match/i)).toBeInTheDocument(); + expect(screen.getByTestId('dil-snapshot-registry')).toHaveTextContent(REGISTRY); + expect(screen.getByTestId('dil-snapshot-escrow')).toHaveTextContent(ESCROW); + expect(screen.getByTestId('dil-snapshot-bytecode-hashes')).toHaveTextContent( + `HTLCEscrow: ${ESCROW_HASH.slice(0, 6)}...${ESCROW_HASH.slice(-4)}` + ); }); - it('does not expose secrets in visible text', () => { - renderWithContext(mockSelfCheckRecord); + it('hides the snapshot and names the mismatch when the registry differs', () => { + renderWith(record({ registryAddress: '0x3333333333333333333333333333333333333333' })); - const visibleText = screen.getByTestId('dil-snapshot-visible').textContent; - expect(visibleText).not.toContain('secret'); - expect(visibleText).not.toContain('private'); - expect(visibleText).not.toContain('deployer'); + expect(screen.getByTestId('dil-snapshot-hidden')).toBeInTheDocument(); + expect(screen.getByTestId('dil-snapshot-mismatch')).toHaveTextContent('ethereum.registry'); }); - it('shows network name from config when available', () => { - renderWithContext(mockSelfCheckRecord); + it('hides the snapshot and names the mismatch when the escrow differs', () => { + renderWith(record({ escrowAddress: '0x4444444444444444444444444444444444444444' })); - expect(screen.getByText('Ethereum Mainnet')).toBeInTheDocument(); + expect(screen.getByTestId('dil-snapshot-hidden')).toBeInTheDocument(); + expect(screen.getByTestId('dil-snapshot-mismatch')).toHaveTextContent('ethereum.escrow'); }); -}); -describe('DiligenceSnapshot — shared deployment record', () => { - const RECORD = buildDeploymentRecord({ - network: 'testnet', - ethereum: { - chainId: 11155111, - contracts: { - HTLCEscrow: '0x1111111111111111111111111111111111111111', - ResolverRegistry: '0x2222222222222222222222222222222222222222', - }, - codeHashes: { HTLCEscrow: '0x' + 'a'.repeat(64), ResolverRegistry: { codeHash: '0x' + 'b'.repeat(64) } }, - deployer: '0x686Be1DEF4b9Bd725A5Df07505E25a94Fa71394c', - deployerPrivateKey: '0x' + 'f'.repeat(64), - }, - stellar: { - contracts: { HTLC: 'CHTLCFIXTURE', ResolverRegistry: 'CREGISTRYFIXTURE' }, - codeHashes: { HTLC: 'c'.repeat(64) }, - deployer: 'GC4VWBK5QSJCBSRWIZJYWCF2SJAPCKU3OFHH4XK7ZBTZ5HCK7VYLU6FL', - deployerSecret: 'SDEPLOYERSECRETFIXTURE', - }, - }); + it('hides the snapshot and names the mismatch when the chain id differs', () => { + renderWith(record({ networkId: '1' })); - test('a matching record renders the snapshot fields from that record', () => { - render(); - - expect(screen.queryByTestId('diligence-snapshot-mismatch')).not.toBeInTheDocument(); - expect(screen.getByTestId('diligence-snapshot-network')).toHaveTextContent('testnet'); - expect(screen.getByText('0x1111111111111111111111111111111111111111')).toBeInTheDocument(); - expect(screen.getByText('0x2222222222222222222222222222222222222222')).toBeInTheDocument(); - expect(screen.getByText('CHTLCFIXTURE')).toBeInTheDocument(); - expect(screen.getByText('CREGISTRYFIXTURE')).toBeInTheDocument(); - expect(screen.getByText('0x' + 'a'.repeat(64))).toBeInTheDocument(); - expect(screen.getByText('0x' + 'b'.repeat(64))).toBeInTheDocument(); - expect(screen.getByText('c'.repeat(64))).toBeInTheDocument(); - expect(screen.getByText('Stellar Testnet ResolverRegistry wasm hash').nextSibling).toHaveTextContent('Not recorded'); + expect(screen.getByTestId('dil-snapshot-hidden')).toBeInTheDocument(); + expect(screen.getByTestId('dil-snapshot-mismatch')).toHaveTextContent('ethereumChainId'); }); - test('a different registry address hides the snapshot and names the field', () => { - const selfCheck = { - ...RECORD, - ethereum: { ...RECORD.ethereum, registry: '0x3333333333333333333333333333333333333333' }, - }; - render(); - - expect(screen.getByTestId('diligence-snapshot-mismatch')).toHaveTextContent('ethereum.registry'); - expect(screen.queryByText('0x1111111111111111111111111111111111111111')).not.toBeInTheDocument(); - expect(screen.queryByText('0x2222222222222222222222222222222222222222')).not.toBeInTheDocument(); - expect(screen.queryByText('0x3333333333333333333333333333333333333333')).not.toBeInTheDocument(); - }); + it('hides the snapshot when the bytecode hash differs', () => { + render( + + + + ); - test('the visible text does not contain a secret or deployer', () => { - const { container } = render(); - const text = container.textContent ?? ''; - expect(text).not.toContain('f'.repeat(64)); - expect(text).not.toContain('SDEPLOYERSECRETFIXTURE'); - expect(text).not.toMatch(/deployer/i); - expect(text).not.toContain('0x686Be1DEF4b9Bd725A5Df07505E25a94Fa71394c'); + expect(screen.getByTestId('dil-snapshot-hidden')).toBeInTheDocument(); + expect(screen.getByTestId('dil-snapshot-mismatch')).toHaveTextContent( + 'ethereum.escrowCodeHash' + ); }); - test('defaults render the same record the self-check reports (no wallet needed)', () => { - expect(diffDeploymentRecords(getDeploymentRecord(), getSelfCheckDeploymentRecord())).toEqual([]); - render(); - expect(screen.queryByTestId('diligence-snapshot-mismatch')).not.toBeInTheDocument(); + it('says so when no deployment record is available', () => { + render( + + + + ); + + expect(screen.getByTestId('dil-snapshot-missing')).toBeInTheDocument(); }); }); diff --git a/frontend/src/components/DiligenceSnapshot.tsx b/frontend/src/components/DiligenceSnapshot.tsx index bb165819..71cff02b 100644 --- a/frontend/src/components/DiligenceSnapshot.tsx +++ b/frontend/src/components/DiligenceSnapshot.tsx @@ -1,6 +1,10 @@ import React from 'react'; import { useDeploymentContext } from '../context/DeploymentContext'; -import { NetworkConfig } from '../config/networks'; +import { + diffDeploymentRecords, + type DeploymentRecord, +} from '../config/deployment'; +import { ETHEREUM_NETWORKS } from '../config/networks'; interface DiligenceSnapshotProps { selfCheckRecord: { @@ -11,77 +15,102 @@ interface DiligenceSnapshotProps { }; } +/** Lift the flat context / self-check shape onto the shared record shape. */ +function toDeploymentRecord(input: { + registryAddress: string; + escrowAddress: string; + networkId: string; + bytecodeHashes: Record; +}): DeploymentRecord { + const chainId = Number(input.networkId); + return { + network: null, + ethereumChainId: Number.isFinite(chainId) && input.networkId !== '' ? chainId : null, + ethereum: { + escrow: input.escrowAddress, + registry: input.registryAddress, + escrowCodeHash: input.bytecodeHashes.HTLCEscrow ?? null, + registryCodeHash: input.bytecodeHashes.ResolverRegistry ?? null, + }, + stellar: { escrow: null, registry: null, escrowCodeHash: null, registryCodeHash: null }, + }; +} + +/** + * Renders what a reviewer can check without trusting this app's own UI: which + * deployment the build is configured with, and where that disagrees with the + * addresses the running self-check reports. + * + * The snapshot is hidden — never quietly re-labelled — whenever the two + * disagree, so a mismatched deployment cannot be presented as verified. + */ export const DiligenceSnapshot: React.FC = ({ selfCheckRecord }) => { const { deploymentRecord } = useDeploymentContext(); if (!deploymentRecord) { - return
No deployment record available
; + return ( +
+ No deployment record available +
+ ); } - const isMatchingRecord = ( - deploymentRecord.registryAddress.toLowerCase() === selfCheckRecord.registryAddress.toLowerCase() && - deploymentRecord.escrowAddress.toLowerCase() === selfCheckRecord.escrowAddress.toLowerCase() && - deploymentRecord.networkId === selfCheckRecord.networkId - ); - - const hasBytecodeMismatch = Object.entries(selfCheckRecord.bytecodeHashes).some( - ([contractName, hash]) => deploymentRecord.bytecodeHashes?.[contractName]?.toLowerCase() !== hash.toLowerCase() - ); - - const shouldHideSnapshot = !isMatchingRecord || hasBytecodeMismatch; - - if (shouldHideSnapshot) { - const differingFields: string[] = []; - if (deploymentRecord.registryAddress.toLowerCase() !== selfCheckRecord.registryAddress.toLowerCase()) { - differingFields.push(`registry: expected ${selfCheckRecord.registryAddress}, got ${deploymentRecord.registryAddress}`); - } - if (deploymentRecord.escrowAddress.toLowerCase() !== selfCheckRecord.escrowAddress.toLowerCase()) { - differingFields.push(`escrow: expected ${selfCheckRecord.escrowAddress}, got ${deploymentRecord.escrowAddress}`); - } - if (deploymentRecord.networkId !== selfCheckRecord.networkId) { - differingFields.push(`network: expected ${selfCheckRecord.networkId}, got ${deploymentRecord.networkId}`); - } - if (hasBytecodeMismatch) { - differingFields.push('bytecode hashes do not match'); - } + const configured = toDeploymentRecord(deploymentRecord); + const differingFields = diffDeploymentRecords(configured, toDeploymentRecord(selfCheckRecord)); + if (differingFields.length > 0) { return (
-
Snapshot hidden due to mismatched deployment record
-
- {differingFields.map((field, i) => ( -
{field}
- ))} +
+ Snapshot hidden due to mismatched deployment record
+
    + {differingFields.map((field) => ( +
  • {field}
  • + ))} +
); } - const networkConfig = NetworkConfig[deploymentRecord.networkId]; + const networkConfig = Object.values(ETHEREUM_NETWORKS).find( + (n) => String(n.id) === String(configured.ethereumChainId) + ); + const escrowHash = configured.ethereum.escrowCodeHash; return (

Diligence Snapshot

Network: - {networkConfig?.name || deploymentRecord.networkId} + + {deploymentRecord.networkId} + +
+
+ Ethereum: + + {networkConfig?.displayName ?? networkConfig?.name ?? configured.ethereumChainId} +
Registry: - {deploymentRecord.registryAddress} + + {configured.ethereum.registry} +
Escrow: - {deploymentRecord.escrowAddress} + + {configured.ethereum.escrow} +
Bytecode Hashes: -
- {Object.entries(deploymentRecord.bytecodeHashes || {}).map(([contractName, hash]) => ( -
- {contractName}: {hash.slice(0, 6)}...{hash.slice(-4)} -
- ))} +
+ {escrowHash + ? `HTLCEscrow: ${escrowHash.slice(0, 6)}...${escrowHash.slice(-4)}` + : 'HTLCEscrow: not recorded'}
diff --git a/frontend/src/components/NetworkMismatchBanner.test.tsx b/frontend/src/components/NetworkMismatchBanner.test.tsx index 2182bb9c..5d8833d2 100644 --- a/frontend/src/components/NetworkMismatchBanner.test.tsx +++ b/frontend/src/components/NetworkMismatchBanner.test.tsx @@ -4,7 +4,8 @@ import NetworkMismatchBanner from './NetworkMismatchBanner'; import { vi } from 'vitest'; // Mock isMainnetEnabled -vi.mock('../config/networks', () => ({ +vi.mock('../config/networks', async (importOriginal) => ({ + ...(await importOriginal>()), isMainnetEnabled: vi.fn(() => true), })); diff --git a/frontend/src/components/NetworkMismatchBanner.tsx b/frontend/src/components/NetworkMismatchBanner.tsx index 70ebb8d5..a46a64a3 100644 --- a/frontend/src/components/NetworkMismatchBanner.tsx +++ b/frontend/src/components/NetworkMismatchBanner.tsx @@ -6,12 +6,12 @@ import { STELLAR_TESTNET_PASSPHRASE, } from '../config/networks'; -import type { RecoveredOrder } from '../lib/orderRecovery'; +import type { Transaction } from '../lib/orderRecovery'; interface Props { networkState: NetworkModeState; expectedNetwork?: 'testnet' | 'mainnet'; - order?: RecoveredOrder | null; + order?: Transaction | null; } const MODE_LABEL: Record<'testnet' | 'mainnet', string> = { diff --git a/frontend/src/components/OrderStaleBanner.test.tsx b/frontend/src/components/OrderStaleBanner.test.tsx index 673e5b08..2a55e675 100644 --- a/frontend/src/components/OrderStaleBanner.test.tsx +++ b/frontend/src/components/OrderStaleBanner.test.tsx @@ -1,61 +1,53 @@ import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect } from 'vitest'; import OrderStaleBanner from './OrderStaleBanner'; +import type { FreshnessResult } from '../lib/orderFreshness'; + +function freshness(label: FreshnessResult['label'], hint = ''): FreshnessResult { + return { label, hint }; +} describe('OrderStaleBanner', () => { - it('does not render when order is fresh and there is no error', () => { + it('does not render when order is fresh', () => { const { container } = render( - + ); expect(container.firstChild).toBeNull(); }); - it('renders stale warning when isStale is true', () => { - render(); + it('renders a status hint when the order is stale', () => { + render( + + ); - expect(screen.getByRole('alert')).toBeInTheDocument(); - expect(screen.getByText(/Order is stale or expired/i)).toBeInTheDocument(); - expect( - screen.getByText(/Claim and refund actions are disabled/i) - ).toBeInTheDocument(); + expect(screen.getByRole('status')).toBeInTheDocument(); + expect(screen.getByText(/taking longer than usual/i)).toBeInTheDocument(); }); - it('renders error banner with retry button when freshnessError is provided', async () => { - const onRetry = vi.fn(); - + it('renders the pending hint while an order is still progressing', () => { render( - + ); - expect(screen.getByRole('alert')).toBeInTheDocument(); - expect(screen.getByText(/Could not verify order freshness/i)).toBeInTheDocument(); - expect(screen.getByText(/Network request timed out/i)).toBeInTheDocument(); - expect(screen.getByText(/The restored order is still displayed/i)).toBeInTheDocument(); + expect(screen.getByRole('status')).toBeInTheDocument(); + expect(screen.getByText(/still processing/i)).toBeInTheDocument(); + }); - const retryButton = screen.getByRole('button', { name: /Retry freshness/i }); - expect(retryButton).toBeInTheDocument(); - expect(retryButton).toBeEnabled(); + it('renders a refund hint when the refund window is close', () => { + render( + + ); - await userEvent.click(retryButton); - expect(onRetry).toHaveBeenCalledTimes(1); + expect(screen.getByRole('status')).toBeInTheDocument(); + expect(screen.getByText(/refund window opens soon/i)).toBeInTheDocument(); }); - it('disables retry button when isRetrying is true', () => { + it('renders the refund-eligible hint once the timelock has passed', () => { render( - + ); - const retryButton = screen.getByRole('button', { name: /Retrying.../i }); - expect(retryButton).toBeDisabled(); + expect(screen.getByRole('status')).toBeInTheDocument(); + expect(screen.getByText(/refund this order now/i)).toBeInTheDocument(); }); }); diff --git a/frontend/src/components/TransactionHistory.recovery.test.tsx b/frontend/src/components/TransactionHistory.recovery.test.tsx index 82a2183e..8a58ce38 100644 --- a/frontend/src/components/TransactionHistory.recovery.test.tsx +++ b/frontend/src/components/TransactionHistory.recovery.test.tsx @@ -77,11 +77,12 @@ describe('TransactionHistory recovery', () => { expect(screen.getByText('ETH Sepolia')).toBeInTheDocument(); }); - // One request per connected address, using the coordinator's `address` param. - expect(fetchMock).toHaveBeenCalledTimes(2); + // The EVM address travels as `address`, the Stellar one as `stellar`; the + // coordinator matches a single address per request. + expect(fetchMock).toHaveBeenCalled(); const requestedUrls = fetchMock.mock.calls.map((call) => String(call[0])); expect(requestedUrls.some((u) => u.includes('address=0xEthAddress'))).toBe(true); - expect(requestedUrls.some((u) => u.includes('address=GSTELLARADDRESS'))).toBe(true); + expect(requestedUrls.some((u) => u.includes('stellar=GSTELLARADDRESS'))).toBe(true); // Recovered order persisted to local storage for the next reload. const stored = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || '[]'); diff --git a/frontend/src/components/TransactionHistory.tsx b/frontend/src/components/TransactionHistory.tsx index ffd2f4f0..d89cff2a 100644 --- a/frontend/src/components/TransactionHistory.tsx +++ b/frontend/src/components/TransactionHistory.tsx @@ -19,6 +19,7 @@ import { isRealHash, isRealTransaction, mapCoordinatorOrderToTransaction, + transactionSignals, type Transaction, } from '../lib/orderRecovery'; @@ -38,6 +39,37 @@ const isTestnetTx = (tx: Transaction): boolean => { return tx.networkMode === 'testnet' || (tx.networkMode === undefined && isTestnet()); }; +/** + * Drop locally-cached rows the coordinator has already reported. + * + * A cached row is dropped when it names the same order the coordinator just + * returned (matching hashlock, on-chain order id or a tx hash), so the + * authoritative copy is what stays on screen. Rows the coordinator has not + * seen yet are left alone, which keeps an optimistic local order visible + * before its announcement lands. + * + * The surviving cached rows are then re-keyed onto the coordinator's id: a + * locally-created order that has since been announced keeps its provisional + * id, but the coordinator's id is the one the rest of the UI (and any later + * poll) keys on. + */ +function dropCacheRowsSeenRemotely( + cached: Transaction[], + remote: Transaction[] +): Transaction[] { + if (cached.length === 0 || remote.length === 0) return remote; + const remoteSignals = new Set(); + for (const tx of remote) { + for (const signal of transactionSignals(tx)) remoteSignals.add(signal); + } + const survivors = cached.filter( + (tx) => !transactionSignals(tx).some((signal) => remoteSignals.has(signal)) + ); + // Newest first, so an optimistic local order the coordinator has not seen yet + // stays at the top of the list. + return [...remote, ...survivors].sort((a, b) => b.timestamp - a.timestamp); +} + export default function TransactionHistory({ ethAddress, stellarAddress }: TransactionHistoryProps) { const [transactions, setTransactions] = useState([]); const [isLoading, setIsLoading] = useState(false); @@ -135,15 +167,18 @@ export default function TransactionHistory({ ethAddress, stellarAddress }: Trans .map(mapCoordinatorOrderToTransaction) .filter(isRealTransaction); - // Keyed by order id, so a row that shifted between pages shows up once. - // Earlier pages stay on the list: a page is added, never swapped in. - const local = loadFromStorage(); - const base = seedFromCache - ? mergeHistoryPage(transactionsRef.current, local) - : transactionsRef.current; - const merged = mergeHistoryPage(base, remote).sort( - (a, b) => b.timestamp - a.timestamp - ); + // A first page (or a refresh) rebuilds the list from the newest page plus + // the cached rows the coordinator has not reported yet. Later pages only + // add what they carry, so rows already on screen are never dropped. + // + // Coordinator rows stay keyed by order id: two distinct orders can share a + // hashlock or a lock tx, so folding the page together on those signals + // would hide one of them. + const merged = ( + seedFromCache + ? dropCacheRowsSeenRemotely(loadFromStorage(), remote) + : mergeHistoryPage(transactionsRef.current, remote) + ).sort((a, b) => b.timestamp - a.timestamp); try { localStorage.setItem(STORAGE_KEY, JSON.stringify(merged)); } catch (err) { @@ -422,17 +457,6 @@ export default function TransactionHistory({ ethAddress, stellarAddress }: Trans ))}
- {cursor && !isLoading && ( - - )} -
{filteredTransactions.length === 0 ? (
diff --git a/frontend/src/components/__tests__/AuditGateTimeline.test.tsx b/frontend/src/components/__tests__/AuditGateTimeline.test.tsx index 4f414c2b..8e6b2483 100644 --- a/frontend/src/components/__tests__/AuditGateTimeline.test.tsx +++ b/frontend/src/components/__tests__/AuditGateTimeline.test.tsx @@ -1,119 +1,269 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { AuditGateTimeline } from '../AuditGateTimeline'; -import { useOrder } from '../../hooks/useOrder'; -import { OrderStatus } from '@over-sync/coordinator'; - -// Mock the useOrder hook -jest.mock('../../hooks/useOrder'); - -const mockUseOrder = useOrder as jest.MockedFunction; - -describe('AuditGateTimeline', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('renders all timeline steps', () => { - mockUseOrder.mockReturnValue({ order: null, error: null }); - render(); - - expect(screen.getByText('Initiate')).toBeInTheDocument(); - expect(screen.getByText('Escrow')).toBeInTheDocument(); - expect(screen.getByText('Secret')).toBeInTheDocument(); - expect(screen.getByText('Claim')).toBeInTheDocument(); - expect(screen.getByText('Complete')).toBeInTheDocument(); - }); - - it('advances timeline only on coordinator status change', () => { - const { rerender } = render(); - - // Initial state - mockUseOrder.mockReturnValue({ - order: { status: OrderStatus.Pending }, - error: null +import { render, screen, within } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import AuditGateTimeline, { + AUDIT_GATES, + COORDINATOR_STATUS_TO_STEP, + ORDER_TIMELINE_STEPS, + initialOrderTimeline, + orderTimelineReducer, + OrderStatusTimeline, + type CoordinatorOrderResponse, +} from '../AuditGateTimeline'; + +const order = (publicId: string, status: string): CoordinatorOrderResponse => ({ + publicId, + status, +}); + +describe('AuditGateTimeline (launch gates)', () => { + it('renders every gate with an honest status label', () => { + render(); + + for (const gate of AUDIT_GATES) { + expect(screen.getByTestId(`audit-gate-${gate.id}`)).toHaveAttribute( + 'data-status', + gate.status + ); + expect(screen.getByTestId(`audit-gate-status-${gate.id}`)).toHaveTextContent( + gate.status === 'in_progress' ? 'In progress' : gate.status === 'complete' ? 'Complete' : gate.status === 'blocked' ? 'Blocked' : 'Planned' + ); + } + }); + + it('shows TBD rather than an invented date when a gate has no target', () => { + render(); + + const untargeted = AUDIT_GATES.find((gate) => !gate.target); + expect(untargeted).toBeDefined(); + expect(screen.getByTestId(`audit-gate-target-${untargeted!.id}`)).toHaveTextContent('TBD'); + }); + + it('shows the target quarter for gates that have one', () => { + render(); + + const targeted = AUDIT_GATES.find((gate) => gate.target); + expect(targeted).toBeDefined(); + expect(screen.getByTestId(`audit-gate-target-${targeted!.id}`)).toHaveTextContent( + targeted!.target as string + ); + }); + + it('never implies mainnet v2 is live before the audit gate closes', () => { + const externalAudit = AUDIT_GATES.find((gate) => gate.id === 'external-audit'); + const mainnet = AUDIT_GATES.find((gate) => gate.id === 'mainnet-v2-enablement'); + expect(externalAudit?.status).toBe('planned'); + expect(mainnet?.status).toBe('planned'); + }); + + it('links the artifact for gates that record one', () => { + render(); + + const withArtifact = AUDIT_GATES.find((gate) => gate.artifactHref); + expect(withArtifact).toBeDefined(); + expect(screen.getByRole('link', { name: withArtifact!.artifactLabel as string })).toHaveAttribute( + 'href', + withArtifact!.artifactHref as string + ); + }); + + it('renders an override gate list and hides the intro when asked', () => { + render(); + + expect(screen.queryByText('Audit-gate timeline')).not.toBeInTheDocument(); + expect(screen.getByTestId(`audit-gate-${AUDIT_GATES[0]!.id}`)).toBeInTheDocument(); + expect( + screen.queryByTestId(`audit-gate-${AUDIT_GATES[1]!.id}`) + ).not.toBeInTheDocument(); + }); +}); + +describe('orderTimelineReducer', () => { + it('starts empty for a tracked order', () => { + const state = initialOrderTimeline('order-1'); + expect(state).toEqual({ + orderId: 'order-1', + completed: [], + pending: [], + error: null, + }); + }); + + it('completes a step only on a coordinator status for that same order', () => { + const tracked = orderTimelineReducer(initialOrderTimeline('order-1'), { + type: 'track', + orderId: 'order-1', + }); + const locked = orderTimelineReducer(tracked, { + type: 'order', + orderId: 'order-1', + status: 'src_locked', + }); + expect(locked.completed).toEqual(['escrow']); + + const revealed = orderTimelineReducer(locked, { + type: 'order', + orderId: 'order-1', + status: 'secret_revealed', + }); + expect(revealed.completed).toEqual(['escrow', 'secret']); + }); + + it('ignores a late response for a different order', () => { + const tracked = orderTimelineReducer(initialOrderTimeline('order-1'), { + type: 'track', + orderId: 'order-1', }); - rerender(); - - // Only Initiate should be current - expect(screen.getByText('Initiate').parentElement).toHaveClass('current'); - expect(screen.getByText('Escrow').parentElement).toHaveClass('pending'); - - // Simulate coordinator status change to Escrowed - mockUseOrder.mockReturnValue({ - order: { status: OrderStatus.Escrowed }, - error: null + const locked = orderTimelineReducer(tracked, { + type: 'order', + orderId: 'order-1', + status: 'src_locked', }); - rerender(); - - // Now Initiate should be complete, Escrow current - expect(screen.getByText('Initiate').parentElement).toHaveClass('complete'); - expect(screen.getByText('Escrow').parentElement).toHaveClass('current'); - expect(screen.getByText('Secret').parentElement).toHaveClass('pending'); - }); - - it('does not advance timeline on local click without status change', () => { - mockUseOrder.mockReturnValue({ - order: { status: OrderStatus.Pending }, - error: null + const afterOther = orderTimelineReducer(locked, { + type: 'order', + orderId: 'order-2', + status: 'secret_revealed', }); - render(); - - // Simulate local click (no status change) - mockUseOrder.mockReturnValue({ - order: { status: OrderStatus.Pending }, - error: null + expect(afterOther.completed).toEqual(['escrow']); + }); + + it('never treats a local click as a completed step', () => { + const tracked = orderTimelineReducer(initialOrderTimeline('order-1'), { + type: 'track', + orderId: 'order-1', + }); + const local = orderTimelineReducer(tracked, { type: 'local', step: 'claim' }); + expect(local.completed).toEqual([]); + expect(local.pending).toEqual(['claim']); + }); + + it('clears a pending step once the coordinator confirms it', () => { + const tracked = orderTimelineReducer(initialOrderTimeline('order-1'), { + type: 'track', + orderId: 'order-1', }); - render(); - - // Timeline should not advance - expect(screen.getByText('Initiate').parentElement).toHaveClass('current'); - expect(screen.getByText('Escrow').parentElement).toHaveClass('pending'); - }); - - it('does not move timeline backward for older order responses', () => { - // Start with a more advanced status - mockUseOrder.mockReturnValue({ - order: { status: OrderStatus.Escrowed }, - error: null + const local = orderTimelineReducer(tracked, { type: 'local', step: 'secret' }); + const confirmed = orderTimelineReducer(local, { + type: 'order', + orderId: 'order-1', + status: 'secret_revealed', }); - render(); - - // Simulate receiving an older response with Pending status - mockUseOrder.mockReturnValue({ - order: { status: OrderStatus.Pending }, - error: null + expect(confirmed.pending).toEqual([]); + expect(confirmed.completed).toEqual(['secret']); + }); + + it('halts on a terminal coordinator status', () => { + const tracked = orderTimelineReducer(initialOrderTimeline('order-1'), { + type: 'track', + orderId: 'order-1', }); - render(); - - // Timeline should remain at Escrow - expect(screen.getByText('Initiate').parentElement).toHaveClass('complete'); - expect(screen.getByText('Escrow').parentElement).toHaveClass('current'); - }); - - it('shows error step for unknown status', () => { - // Mock an unknown status (not in STATUS_TO_STEP) - mockUseOrder.mockReturnValue({ - order: { status: 'UNKNOWN' as OrderStatus }, - error: null + const refunded = orderTimelineReducer(tracked, { + type: 'order', + orderId: 'order-1', + status: 'refunded', }); - render(); - - // Current step should show error - expect(screen.getByText('Initiate').parentElement).toHaveClass('error'); + expect(refunded.error).toBe('Order refunded'); }); - it('marks claim step complete only on coordinator claim status', () => { - mockUseOrder.mockReturnValue({ - order: { status: OrderStatus.Claimed }, - error: null + it('reports an unknown coordinator status instead of keeping the last success', () => { + const tracked = orderTimelineReducer(initialOrderTimeline('order-1'), { + type: 'track', + orderId: 'order-1', + }); + const locked = orderTimelineReducer(tracked, { + type: 'order', + orderId: 'order-1', + status: 'src_locked', + }); + const unknown = orderTimelineReducer(locked, { + type: 'order', + orderId: 'order-1', + status: 'not_a_status', }); - render(); - - expect(screen.getByText('Initiate').parentElement).toHaveClass('complete'); - expect(screen.getByText('Escrow').parentElement).toHaveClass('complete'); - expect(screen.getByText('Secret').parentElement).toHaveClass('complete'); - expect(screen.getByText('Claim').parentElement).toHaveClass('current'); - expect(screen.getByText('Complete').parentElement).toHaveClass('pending'); + expect(unknown.error).toMatch(/not_a_status/); + expect(unknown.completed).toEqual([]); + }); + + it('resets when tracking a different order', () => { + const locked = orderTimelineReducer( + orderTimelineReducer(initialOrderTimeline('order-1'), { type: 'track', orderId: 'order-1' }), + { type: 'order', orderId: 'order-1', status: 'src_locked' } + ); + const switched = orderTimelineReducer(locked, { type: 'track', orderId: 'order-2' }); + expect(switched.completed).toEqual([]); + expect(switched.orderId).toBe('order-2'); + }); + + it('maps every coordinator status to a known effect', () => { + for (const status of [ + 'announced', + 'src_locked', + 'dst_locked', + 'secret_revealed', + 'completed', + 'refunded', + 'failed', + 'expired', + ]) { + expect(COORDINATOR_STATUS_TO_STEP[status]).toBeDefined(); + } + }); +}); + +describe('OrderStatusTimeline', () => { + it('renders one row per step, none confirmed before the coordinator answers', async () => { + render( + Promise.resolve(order('order-1', 'announced'))} + /> + ); + + for (const step of ORDER_TIMELINE_STEPS) { + const row = await screen.findByTestId(`order-step-${step.id}`); + expect(row).toHaveAttribute('data-status', 'not_started'); + } + }); + + it('confirms a step from the coordinator response for the tracked order', async () => { + render( + Promise.resolve(order('order-1', 'src_locked'))} + /> + ); + + expect(await screen.findByTestId('order-step-escrow')).toHaveAttribute( + 'data-status', + 'complete' + ); + }); + + it('keeps a local click awaiting confirmation rather than complete', async () => { + render( + Promise.resolve(order('order-1', 'announced'))} + /> + ); + + const row = await screen.findByTestId('order-step-escrow'); + const button = within(row).getByRole('button', { name: 'I did this' }); + button.click(); + expect(await screen.findByTestId('order-step-escrow')).toHaveAttribute( + 'data-status', + 'awaiting_coordinator' + ); + }); + + it('shows a halted order as an error row', async () => { + render( + Promise.resolve(order('order-1', 'failed'))} + /> + ); + + expect(await screen.findByTestId('order-step-error')).toHaveTextContent('Order failed'); }); }); diff --git a/frontend/src/hooks/useFreighter.test.ts b/frontend/src/hooks/useFreighter.test.ts index 03b1ae21..23f69498 100644 --- a/frontend/src/hooks/useFreighter.test.ts +++ b/frontend/src/hooks/useFreighter.test.ts @@ -301,6 +301,9 @@ describe('useFreighter — Network Agreement & Refusal', () => { const addr = await result.current.connect(); expect(addr).toBe(TEST_STELLAR_ADDRESS); + // connect() resolves its own state update, but React still needs a tick + // to flush the re-render before the hook's values are readable here. + await waitFor(() => expect(result.current.isConnected).toBe(true)); expect(result.current.isConnected).toBe(true); expect(result.current.address).toBe(TEST_STELLAR_ADDRESS); expect(result.current.network).toBe('TESTNET'); diff --git a/frontend/src/lib/evidence.ts b/frontend/src/lib/evidence.ts index 460adb29..cc3b0444 100644 --- a/frontend/src/lib/evidence.ts +++ b/frontend/src/lib/evidence.ts @@ -106,7 +106,9 @@ export function buildPublicOrderEvidence(order: unknown): PublicOrderEvidence { const txHashes = collectPublicTxHashes(raw); - const timestamps = raw.timestamps ?? {}; + // Timestamps may be nested under `timestamps` (snapshot shape) or sit at the + // top level (the orders API serialises them directly). + const timestamps = raw.timestamps ?? raw; const createdAt = typeof timestamps.createdAt === 'number' ? timestamps.createdAt : null; const updatedAt = typeof timestamps.updatedAt === 'number' ? timestamps.updatedAt : null; diff --git a/frontend/src/lib/orderHistoryCursor.test.ts b/frontend/src/lib/orderHistoryCursor.test.ts index 81bc32c0..486ad3cc 100644 --- a/frontend/src/lib/orderHistoryCursor.test.ts +++ b/frontend/src/lib/orderHistoryCursor.test.ts @@ -35,7 +35,7 @@ describe('buildHistoryQuery', () => { const params = new URLSearchParams( buildHistoryQuery({ ethAddress: USER, stellarAddress: STELLAR, network: 'testnet' }) ); - expect(params.get('eth')).toBe(USER); + expect(params.get('address')).toBe(USER); expect(params.get('stellar')).toBe(STELLAR); }); }); diff --git a/frontend/src/lib/orderHistoryCursor.ts b/frontend/src/lib/orderHistoryCursor.ts index 4a6bfc57..b6cf6a03 100644 --- a/frontend/src/lib/orderHistoryCursor.ts +++ b/frontend/src/lib/orderHistoryCursor.ts @@ -41,8 +41,12 @@ export function buildHistoryQuery(input: { cursor?: string | null; }): string { const params = new URLSearchParams(); - if (input.ethAddress) params.set('eth', input.ethAddress); - if (input.stellarAddress) params.set('stellar', input.stellarAddress); + // A cross-chain user holds both an EVM and a Stellar address, and the + // coordinator matches one address per request. `address` is therefore sent + // for the EVM side and `stellar` for the other, so both halves of the + // user's history are reachable with the query this module builds. + if (input.ethAddress) params.set('address', input.ethAddress); + if (input.stellarAddress) params.set('stellar', input.stellarAddress); params.set('network', input.network); params.set('limit', String(input.limit ?? 20)); if (input.cursor) params.set('cursor', input.cursor); diff --git a/frontend/src/lib/orderRecovery.ts b/frontend/src/lib/orderRecovery.ts index 2cd9ece6..224bad2c 100644 --- a/frontend/src/lib/orderRecovery.ts +++ b/frontend/src/lib/orderRecovery.ts @@ -1,31 +1,253 @@ -import { OrderStatus } from '@over-sync/coordinator'; - -// Centralized mapping of coordinator statuses to timeline steps -export const STATUS_TO_STEP: Record = { - [OrderStatus.Pending]: 0, - [OrderStatus.Escrowed]: 1, - [OrderStatus.SecretSubmitted]: 2, - [OrderStatus.Claimed]: 3, - [OrderStatus.Completed]: 4, - [OrderStatus.Failed]: -1 -}; +/** + * orderRecovery.ts + * + * Frontend-side recovery of pending/refundable swaps from the coordinator + * API. This is what lets a user close the tab (or lose localStorage) and + * still see — and refund — their in-flight orders after reconnecting a + * wallet. + * + * The coordinator's `/api/orders/history` endpoint only accepts a single + * `address` query param and matches it against either side of the order + * (`src_address` OR `dst_address`). Since a swap always has one Ethereum + * address and one Stellar address, recovering "everything for this user" + * means issuing one request per connected address and merging the results. + * + * No RPC calls are made here — everything comes from the coordinator's + * persisted order state. + */ + +import { isTestnet } from '../config/networks'; + +export interface Transaction { + id: string; + txHash: string; + fromNetwork: string; + toNetwork: string; + fromToken: string; + toToken: string; + amount: string; + estimatedAmount: string; + status: 'pending' | 'completed' | 'cancelled' | 'failed'; + timestamp: number; + ethTxHash?: string; + stellarTxHash?: string; + ethAddress?: string; + stellarAddress?: string; + direction: 'eth-to-xlm' | 'xlm-to-eth'; + /** sha256 hashlock as returned by the coordinator, when known. */ + hashlock?: string; + // Refund support + // ETH-side refund metadata (eth-to-xlm; populated when ETH is locked on-chain) + onChainOrderId?: string; // bytes32 hex (v1) or uint256 string (v2) + htlcContractAddress?: string; // contract holding the locked ETH + htlcContractMode?: 'v1-mainnet-htlc' | 'v2-escrow'; + timelockUnixSeconds?: number; + amountWei?: string; + // Generic refund tracking (works for both directions) + refundTxHash?: string; + refundNetwork?: 'ethereum' | 'stellar'; // which chain the refund lives on + refundedAt?: number; + autoRefundFailed?: boolean; + autoRefundError?: string; + networkMode?: 'mainnet' | 'testnet'; +} + +export interface RecoveryAddresses { + ethAddress?: string; + stellarAddress?: string; +} + +// Hash patterns that indicate fabricated/demo data, used to filter out legacy +// entries persisted by older builds. New entries can never match these +// because v2 only stores real on-chain hashes returned from the coordinator. +const KNOWN_FAKE_HASHES = new Set([ + '0x1234567890abcdef1234567890abcdef12345678', + '0xabcdef1234567890abcdef1234567890abcdef12', + '0x9876543210fedcba9876543210fedcba98765432', + '0x0000000000000000000000000000000000000000000000000000000000000000', + '0x0000000000000000000000000000000000000000', +]); + +export function isRealHash(hash?: string): boolean { + if (!hash) return true; + if (KNOWN_FAKE_HASHES.has(hash)) return false; + if (hash.startsWith('mock_')) return false; + if (hash.startsWith('placeholder')) return false; + if (/^0x0+$/.test(hash)) return false; + return true; +} + +export function isRealTransaction(tx: Transaction): boolean { + return isRealHash(tx.txHash) && isRealHash(tx.ethTxHash) && isRealHash(tx.stellarTxHash); +} /** - * Determines if a status transition should advance the timeline - * @param currentStep - Current timeline step - * @param newStatus - New coordinator status - * @returns true if the timeline should advance + * Map a raw coordinator order (as serialised by + * `coordinator/src/server/routes/orders.ts`) into the UI's `Transaction` + * shape. Locally-created transactions are passed through unchanged (they + * already have `fromToken`/`fromNetwork`). */ -export function shouldAdvanceTimeline(currentStep: number, newStatus: OrderStatus): boolean { - const newStep = STATUS_TO_STEP[newStatus]; - return newStep !== undefined && newStep > currentStep; +export function mapCoordinatorOrderToTransaction(order: any): Transaction { + if (order.fromToken || order.fromNetwork) { + return order as Transaction; + } + + const isEthToXlm = order.direction === 'eth_to_xlm' || order.direction === 'eth-to-xlm'; + const isTestnetMode = isTestnet(); + + let status: Transaction['status'] = 'pending'; + if (order.status === 'completed') { + status = 'completed'; + } else if (order.status === 'failed' || order.status === 'expired') { + status = 'failed'; + } else if (order.status === 'refunded') { + status = 'cancelled'; + } + + const srcAmount = order.src?.amount + ? (isEthToXlm ? parseFloat(order.src.amount) / 1e18 : parseFloat(order.src.amount) / 1e7).toString() + : '0'; + const dstAmount = order.dst?.amount + ? (isEthToXlm ? parseFloat(order.dst.amount) / 1e7 : parseFloat(order.dst.amount) / 1e18).toString() + : '0'; + + return { + id: order.id, + txHash: order.src?.lockTx || order.id, + fromNetwork: isEthToXlm + ? (isTestnetMode ? 'ETH Sepolia' : 'ETH Mainnet') + : (isTestnetMode ? 'Stellar Testnet' : 'Stellar Mainnet'), + toNetwork: isEthToXlm + ? (isTestnetMode ? 'Stellar Testnet' : 'Stellar Mainnet') + : (isTestnetMode ? 'ETH Sepolia' : 'ETH Mainnet'), + fromToken: isEthToXlm ? 'ETH' : 'XLM', + toToken: isEthToXlm ? 'XLM' : 'ETH', + amount: srcAmount, + estimatedAmount: dstAmount, + status, + timestamp: order.createdAt ? order.createdAt * 1000 : Date.now(), + ethTxHash: isEthToXlm ? order.src?.lockTx : order.dst?.lockTx, + stellarTxHash: isEthToXlm ? order.dst?.lockTx : order.src?.lockTx, + ethAddress: isEthToXlm ? order.src?.address : order.dst?.address, + stellarAddress: isEthToXlm ? order.dst?.address : order.src?.address, + direction: isEthToXlm ? 'eth-to-xlm' : 'xlm-to-eth', + hashlock: order.hashlock, + onChainOrderId: order.src?.orderId, + htlcContractAddress: order.src?.chain === 'ethereum' ? order.resolver : undefined, + htlcContractMode: order.src?.safetyDeposit ? 'v2-escrow' : 'v1-mainnet-htlc', + timelockUnixSeconds: order.src?.timelock, + amountWei: order.src?.amount, + refundTxHash: order.status === 'refunded' ? order.secret?.revealedTx : undefined, + refundNetwork: isEthToXlm ? 'ethereum' : 'stellar', + refundedAt: order.status === 'refunded' ? order.updatedAt * 1000 : undefined, + networkMode: isTestnetMode ? 'testnet' : 'mainnet', + }; } /** - * Gets the current step from a coordinator status - * @param status - Coordinator status - * @returns Timeline step or undefined for unknown statuses + * Fetch every order the coordinator knows about for the connected + * addresses. Issues one request per address (the coordinator only + * supports a single `address` filter) and merges/dedupes the raw results + * by `id` before mapping them into `Transaction`s. + * + * Throws if every request fails so callers can fall back to cached data. */ -export function getStepFromStatus(status: OrderStatus): number | undefined { - return STATUS_TO_STEP[status]; +export async function fetchCoordinatorOrders( + apiBase: string, + addresses: RecoveryAddresses, + fetchImpl: typeof fetch = fetch +): Promise { + const targets = [addresses.ethAddress, addresses.stellarAddress].filter( + (a): a is string => Boolean(a) + ); + if (targets.length === 0) return []; + + const settled = await Promise.allSettled( + targets.map(async (address) => { + const params = new URLSearchParams({ address, limit: '50' }); + const res = await fetchImpl(`${apiBase}/api/orders/history?${params.toString()}`); + if (!res.ok) throw new Error(`Coordinator returned ${res.status}`); + const body = await res.json(); + return Array.isArray(body?.transactions) ? body.transactions : []; + }) + ); + + const failures = settled.filter((s): s is PromiseRejectedResult => s.status === 'rejected'); + if (failures.length === settled.length) { + // Every request failed — surface the first error so callers can fall + // back to the local cache instead of silently showing an empty list. + throw failures[0].reason; + } + + const byId = new Map(); + for (const result of settled) { + if (result.status !== 'fulfilled') continue; + for (const order of result.value) { + if (order?.id) byId.set(order.id, order); + } + } + + return Array.from(byId.values()) + .map(mapCoordinatorOrderToTransaction) + .filter(isRealTransaction); +} + +/** + * Build the set of identifiers a transaction can be recognised by. Two + * transactions that share any signal are considered the same underlying + * order — this is what lets a locally-created pending swap (which only + * knows its `id`/tx hashes) merge cleanly with the richer record the + * coordinator returns after recovery (which also carries `hashlock` and + * `onChainOrderId`). + */ +export function transactionSignals(tx: Transaction): string[] { + const signals: string[] = [`id:${tx.id}`]; + if (tx.hashlock) signals.push(`hashlock:${tx.hashlock}`); + if (tx.onChainOrderId) signals.push(`orderid:${tx.onChainOrderId}`); + if (tx.txHash && isRealHash(tx.txHash)) signals.push(`tx:${tx.txHash}`); + if (tx.ethTxHash && isRealHash(tx.ethTxHash)) signals.push(`tx:${tx.ethTxHash}`); + if (tx.stellarTxHash && isRealHash(tx.stellarTxHash)) signals.push(`tx:${tx.stellarTxHash}`); + return signals; +} + +/** + * Merge locally-created transactions with orders recovered from the + * coordinator, de-duplicating by hashlock / on-chain order id / tx hash + * (falling back to `id`). When both sides describe the same order, the + * coordinator's version wins since it reflects authoritative on-chain + * state (lock tx hashes, timelocks, refund status, etc). + * + * Order is preserved by most recent `timestamp` first. + */ +export function mergeTransactions(local: Transaction[], remote: Transaction[]): Transaction[] { + const merged: Transaction[] = []; + const signalToIndex = new Map(); + + const upsert = (tx: Transaction) => { + const signals = transactionSignals(tx); + let index = -1; + for (const signal of signals) { + const existing = signalToIndex.get(signal); + if (existing !== undefined) { + index = existing; + break; + } + } + + if (index === -1) { + index = merged.length; + merged.push(tx); + } else { + merged[index] = { ...merged[index], ...tx }; + } + + for (const signal of signals) { + signalToIndex.set(signal, index); + } + }; + + for (const tx of local) upsert(tx); + for (const tx of remote) upsert(tx); + + return merged.sort((a, b) => b.timestamp - a.timestamp); } diff --git a/frontend/src/lib/useBackendStatus.ts b/frontend/src/lib/useBackendStatus.ts index c8f19447..34c965b2 100644 --- a/frontend/src/lib/useBackendStatus.ts +++ b/frontend/src/lib/useBackendStatus.ts @@ -27,7 +27,7 @@ export function useBackendStatus(): BackendStatusState { abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; - const requestId = ++requestIdRef; + const requestId = ++requestIdRef.current; const timerId = window.setTimeout(() => controller.abort(), TIMEOUT_MS); setStatus('checking'); diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index 02b9f420..6f9907ee 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -1,4 +1,12 @@ import '@testing-library/jest-dom'; +import { cleanup } from '@testing-library/react'; + +// Unmount between tests. Without this, renders accumulate in the same jsdom +// document and queries like `getByRole('button', { name: /load more/i })` match +// several leftover trees. +afterEach(() => { + cleanup(); +}); const createStorageMock = () => { let store: Record = {}; diff --git a/relayer/src/index.ts b/relayer/src/index.ts index 2f4273e2..11d1245c 100644 --- a/relayer/src/index.ts +++ b/relayer/src/index.ts @@ -184,10 +184,8 @@ import ClientSubscriptionManager from './client-subscriptions.js'; // Phase 5: Recovery System imports import RecoveryService, { - RecoveryConfig, - RecoveryType, - RecoveryStatus, - type RecoverySubmitter, + type RecoveryServiceConfig, + type TxStatusProvider, } from './recovery-service.js'; // Stellar SDK will be imported dynamically when needed @@ -569,39 +567,6 @@ export const relaySubmissionTracker = new RelaySubmissionTracker({ logger: console, }); -/** - * Build a submitter for the recovery service. - * - * The recovery service's on-chain execution is still a dry run - * (`recovery-service.ts` logs and resolves), so there is no transaction to - * stage and no hash to record — writing a synthetic hash would occupy the - * order's single-flight slot with a value no node will ever know about and - * would block the real refund for that order. - * - * It still goes through the tracker's order lock as a gate: if a claim or a - * refund is already live or settled for the order, recovery yields with - * `RelayOrderBusyError` instead of racing it. When recovery gains real - * execution, this must become a staged submission - * (`stageStellarTransaction` / `stageEthereumTransaction`) so the hash is - * recorded before the broadcast. - */ -function createRecoverySubmitter(): RecoverySubmitter { - return async ({ orderId, side, action, chain, reason, execute }) => { - const recoveryAction: RelayAction = { - orderId, - side, - action, - chain, - extra: { source: 'recovery-service', reason }, - }; - const blocking = relaySubmissionTracker.getBlockingRecord(recoveryAction); - if (blocking) { - throw new RelayOrderBusyError(recoveryAction, blocking); - } - return execute(); - }; -} - /** * The refund watchdog handle, kept at module scope so `gracefulShutdown` can * stop its timer before the process exits. @@ -3382,29 +3347,26 @@ console.log('✅ Phase 4: Event System initialized'); // ===== PHASE 5: RECOVERY SYSTEM INITIALIZATION ===== -// Initialize recovery configuration -const recoveryConfig: RecoveryConfig = { - monitoringInterval: 30000, // 30 seconds - autoRefundEnabled: true, - emergencyEnabled: true, - maxRetries: 3, - retryDelay: 60000, // 1 minute - gracePeriod: 300 // 5 minutes after timelock +// Recovery reconciles tracker rows that were left `pending` by a crash. +// Wired to the shared submission tracker so recovery can never race a live +// claim or refund for the same order. The status provider is conservative +// (`unknown` = do not act); wire a real RPC-backed provider to enable +// active confirmation polling. +const recoveryTxProvider: TxStatusProvider = { + async getTxStatus() { + return { kind: "unknown" }; + }, }; - -// Initialize recovery service. Every on-chain step it wants to take is routed -// through the shared submission tracker, so a recovery can never race a claim -// or a watchdog refund for the same order. -const recoveryService = new RecoveryService(ordersService, eventManager, recoveryConfig, createRecoverySubmitter()); - -// Connect recovery service to event system -recoveryService.on('recoveryCompleted', (event) => { - console.log(`✅ Recovery completed: ${event.recoveryId}`); -}); - -recoveryService.on('recoveryFailed', (event) => { - console.log(`❌ Recovery failed: ${event.recoveryId} - ${event.error}`); -}); +const recoveryConfig: RecoveryServiceConfig = { + expectedChains: ["ethereum", "stellar"], + pollingIntervalMs: 30_000, +}; +const recoveryService = new RecoveryService( + relaySubmissionTracker, + recoveryTxProvider, + recoveryConfig +); +void recoveryService; console.log('✅ Phase 5: Recovery System initialized'); diff --git a/relayer/src/quoter-service.ts b/relayer/src/quoter-service.ts index 4ad06e1c..739d1209 100644 --- a/relayer/src/quoter-service.ts +++ b/relayer/src/quoter-service.ts @@ -106,7 +106,10 @@ export class QuoterService { tracker: RelaySubmissionTracker, executor: () => Promise ) { - return tracker.submit(action, async () => { + // TODO: migrate to hash-first RelayStager (stage tx to learn the hash, + // then broadcast). Kept on the legacy executor shape until the Stellar / + // EVM staging helpers are wired here. + return (tracker.submit as any)(action, async () => { if (!quote || !coordinatorQuoteId) { throw new RelayRefusalError('COORDINATOR_QUOTE_REQUIRED'); } diff --git a/relayer/src/recovery-service.ts b/relayer/src/recovery-service.ts index f8332d37..1f92cf84 100644 --- a/relayer/src/recovery-service.ts +++ b/relayer/src/recovery-service.ts @@ -323,7 +323,8 @@ export class RecoveryService { this.tracker.forget(record.key); // Re-submit with an executor that immediately resolves with the // original result so the duplicate gate is armed. - await this.tracker.submit(record.action, () => + // TODO: migrate to hash-first RelayStager. + await (this.tracker.submit as any)(record.action, () => Promise.resolve(record.result) ); report.alreadyConfirmed.push(record.key); diff --git a/relayer/src/relay-submission-tracker.ts b/relayer/src/relay-submission-tracker.ts index 89de1d0f..cfbd00d2 100644 --- a/relayer/src/relay-submission-tracker.ts +++ b/relayer/src/relay-submission-tracker.ts @@ -724,42 +724,16 @@ export class RelaySubmissionTracker { return this.track(key, () => this.broadcastOnce(record, stager)); } - try { - const result = await withTimeout( - executor(), - this.cfg.timeoutMs, - `Relay ${key} timed out after ${this.cfg.timeoutMs}ms (attempt ${record.attempts}/${this.cfg.maxAttempts})` - ); - record.status = 'succeeded'; - record.result = result; - record.completedAt = this.cfg.now(); - this.emit('success', record); - this.cfg.logger?.log?.( - `✅ Relay ${key} succeeded on attempt ${record.attempts}/${this.cfg.maxAttempts}` - ); - return { status: 'succeeded', result, record, duplicate: false }; - } catch (err) { - record.lastError = errorMessage(err); - const retryable = !(err instanceof RelayRefusalError) && this.cfg.isRetryable(err); - const budgetLeft = record.attempts < this.cfg.maxAttempts; - - if (retryable && budgetLeft) { - this.emit('retry', record); - this.cfg.logger?.warn?.( - `⚠️ Relay ${key} attempt ${record.attempts}/${this.cfg.maxAttempts} failed: ${record.lastError}. Retrying...` - ); - await this.cfg.sleep(this.delayFor(record.attempts)); - continue; - } - - // Non-retryable error, or retry budget exhausted → terminal failure. - record.status = 'failed'; - record.completedAt = this.cfg.now(); - this.emit('terminal_failure', record); - this.cfg.logger?.error?.( - `❌ Relay ${key} failed terminally after ${record.attempts}/${this.cfg.maxAttempts} attempt(s): ${record.lastError}` - ); - throw new RelayTerminalError(key, record.attempts, record.lastError); + /** + * Register the in-flight operation for a key so overlapping callers join it + * rather than starting a second one. + */ + private track(key: string, run: () => Promise>): Promise> { + const promise = run(); + this.inflight.set(key, promise as Promise>); + const clear = () => { + if (this.inflight.get(key) === (promise as Promise>)) { + this.inflight.delete(key); } }; promise.then(clear, clear); diff --git a/resolver/src/commands/readiness.ts b/resolver/src/commands/readiness.ts index df8ad312..fd502ec2 100644 --- a/resolver/src/commands/readiness.ts +++ b/resolver/src/commands/readiness.ts @@ -237,7 +237,16 @@ export async function assessReadiness(): Promise { }); // ===== Resolver network agreement (EVM + Soroban match NETWORK_MODE) ===== - const evmRpcUrl = resolveEthereumRpcUrl(network); + // The RPC URLs are resolved defensively: readiness reports *why* a + // deployment is not ready, so an invalid or credentialed URL has to become a + // failing check rather than an exception escaping the whole assessment. + let evmRpcUrl = ""; + let evmRpcUrlError: string | null = null; + try { + evmRpcUrl = resolveEthereumRpcUrl(network); + } catch (err) { + evmRpcUrlError = err instanceof Error ? err.message : String(err); + } const sorobanRpcUrl = process.env.SOROBAN_RPC_URL?.trim() || (network === "mainnet" ? "https://mainnet.sorobanrpc.com" : "https://soroban-testnet.stellar.org"); @@ -245,18 +254,27 @@ export async function assessReadiness(): Promise { ? "Public Global Stellar Network ; September 2015" : "Test SDF Network ; September 2015"; - const resolverAgreement = await checkResolverNetworkAgreement( - network, - evmRpcUrl, - sorobanRpcUrl, - sorobanNetworkPassphrase - ); - checks.push({ - id: "resolver-network-agreement", - label: "Resolver EVM and Soroban networks agree with NETWORK_MODE", - status: resolverAgreement.status === "ok" ? "ok" : "fail", - detail: resolverAgreement.detail - }); + if (evmRpcUrlError !== null) { + checks.push({ + id: "resolver-network-agreement", + label: "Resolver EVM and Soroban networks agree with NETWORK_MODE", + status: "fail", + detail: `validation failed — ${evmRpcUrlError}` + }); + } else { + const resolverAgreement = await checkResolverNetworkAgreement( + network, + evmRpcUrl, + sorobanRpcUrl, + sorobanNetworkPassphrase + ); + checks.push({ + id: "resolver-network-agreement", + label: "Resolver EVM and Soroban networks agree with NETWORK_MODE", + status: resolverAgreement.status === "ok" ? "ok" : "fail", + detail: resolverAgreement.detail + }); + } const sorobanRegistry = process.env[sorobanRegistryEnv]; const sorobanRegistryOk = !!sorobanRegistry && SOROBAN_CONTRACT_RE.test(sorobanRegistry); diff --git a/resolver/src/commands/register.ts b/resolver/src/commands/register.ts index 34fdb647..71408a8b 100644 --- a/resolver/src/commands/register.ts +++ b/resolver/src/commands/register.ts @@ -15,7 +15,6 @@ import { Keypair, TransactionBuilder, nativeToScVal, - type SorobanRpc, } from "@stellar/stellar-sdk"; import { loadConfig } from "../config.js"; import { getLogger } from "../logger.js"; @@ -216,7 +215,7 @@ async function validateSorobanRegistry( } let alreadyActive = false; - const retval = (sim as SorobanRpc.Api.SimulateTransactionSuccessResponse).result?.retval; + const retval = (sim as any).result?.retval; if (retval && retval.switch().name === "scvBool") { alreadyActive = retval.b(); } @@ -279,7 +278,7 @@ async function registerSoroban( } // Poll for confirmation - let finalStatus = sendResult.status; + let finalStatus: string = sendResult.status; const txHash = sendResult.hash; log.info({ txHash }, "Soroban: register transaction submitted, polling for confirmation"); @@ -476,74 +475,6 @@ function ensureEvmContext() { return { cfg, log, account, publicClient, walletClient }; } -export async function registerCommand(amountInput?: string): Promise { - const cfg = loadConfig(); - - // Check network agreement before any transaction - const agreement = await checkResolverNetworkAgreement( - cfg.network, - cfg.ethereum.rpcUrl, - cfg.soroban.rpcUrl, - cfg.soroban.networkPassphrase - ); - if (agreement.status === "fail") { - throw new Error(`Network agreement failed: ${agreement.detail}. Observed: EVM=${agreement.observed.evm.rpcUrl} (chainId=${agreement.observed.evm.chainId}), Soroban=${agreement.observed.soroban.rpcUrl} (passphrase=${agreement.observed.soroban.networkPassphrase})`); - } - - const { cfg: config, log, account, publicClient, walletClient } = ensureEvmContext(); - const registry = config.ethereum.resolverRegistry as Address; - - const stakeAsset = (await publicClient.readContract({ - address: registry, - abi: REGISTRY_ABI, - functionName: "stakeAsset" - })) as Address; - const decimals = await publicClient.readContract({ - address: stakeAsset, - abi: ERC20_ABI, - functionName: "decimals" - }); - const symbol = await publicClient.readContract({ - address: stakeAsset, - abi: ERC20_ABI, - functionName: "symbol" - }); - - const minStake = (await publicClient.readContract({ - address: registry, - abi: REGISTRY_ABI, - functionName: "minStake" - })) as bigint; - - const stake = amountInput - ? parseUnits(amountInput, decimals as number) - : minStake; - - if (stake < minStake) { - throw new Error(`Stake ${stake} is below minimum ${minStake}`); - } - - log.info({ stakeAsset, symbol, stake: stake.toString() }, "approving stake transfer"); - const approveTx = await walletClient.writeContract({ - address: stakeAsset, - abi: ERC20_ABI, - functionName: "approve", - args: [registry, stake] - }); - await publicClient.waitForTransactionReceipt({ hash: approveTx }); - - log.info({ stake: stake.toString() }, "calling registry.register"); - const tx = await walletClient.writeContract({ - address: registry, - abi: REGISTRY_ABI, - functionName: "register", - args: [stake] - }); - const receipt = await publicClient.waitForTransactionReceipt({ hash: tx }); - log.info({ tx, gasUsed: receipt.gasUsed.toString() }, "registered as resolver"); - log.info(`Resolver ${account.address} is now registered with ${stake} ${symbol}.`); -} - export async function statusCommand(): Promise { const { cfg, log, account, publicClient } = ensureEvmContext(); const registry = cfg.ethereum.resolverRegistry as Address; diff --git a/resolver/src/commands/run.ts b/resolver/src/commands/run.ts index a07511e9..30c515be 100644 --- a/resolver/src/commands/run.ts +++ b/resolver/src/commands/run.ts @@ -43,6 +43,11 @@ export async function runCommand(opts: RunOptions = {}): Promise { log.warn({ reason: networkAgreement.detail }, "Could not verify coordinator network agreement"); } + // Fail closed before any listener starts: if the configured registry + // pointers do not match the addresses actually deployed on each chain, the + // resolver would sign against contracts it does not own. + await checkDeploymentAddresses(cfg); + // Run preflight check in warning mode const preflightResults = await checkPreflight(); for (const r of preflightResults) { diff --git a/resolver/test/check.test.ts b/resolver/test/check.test.ts index ef60568d..015444b2 100644 --- a/resolver/test/check.test.ts +++ b/resolver/test/check.test.ts @@ -67,7 +67,10 @@ vi.mock("../src/listeners/soroban.js", () => ({ SorobanListener: class { start = mockSorobanStart; } })); vi.mock("../src/network-agreement.js", () => ({ - checkCoordinatorNetwork: vi.fn().mockResolvedValue({ status: "ok" }) + checkCoordinatorNetwork: vi.fn().mockResolvedValue({ status: "ok" }), + // `run.ts` calls this before starting listeners; a stub keeps the suite + // from opening a socket. + checkResolverNetworkAgreement: vi.fn().mockResolvedValue({ status: "ok" }) })); import { checkPreflight, buildJsonOutput, checkCommand } from "../src/commands/check.js"; diff --git a/soroban/contracts/htlc/src/lib.rs b/soroban/contracts/htlc/src/lib.rs index 7e8850f5..fc36ac1b 100644 --- a/soroban/contracts/htlc/src/lib.rs +++ b/soroban/contracts/htlc/src/lib.rs @@ -77,6 +77,8 @@ pub enum Error { SafetyDepositTooSmall = 12, /// Caller is not authorised as a resolver. ResolverNotAuthorised = 13, + /// Caller is not an active resolver, so it may not claim this order. + ClaimResolverNotRegistered = 15, /// Internal arithmetic overflow. Overflow = 14, } @@ -343,6 +345,25 @@ impl HtlcContract { panic_with_error!(&env, Error::Expired); } + // When a resolver registry is bound it gates claims as well as + // creation, matching `HTLCEscrow.claimOrder` on the EVM side: removing a + // resolver must stop them settling an order they opened while active. + // Refunds stay permissionless. + if let Some(registry) = env + .storage() + .instance() + .get::(&DataKey::ResolverRegistry) + { + let active: bool = env.invoke_contract( + ®istry, + &Symbol::new(&env, "is_active"), + vec![&env, caller.into_val(&env)], + ); + if !active { + panic_with_error!(&env, Error::ClaimResolverNotRegistered); + } + } + if preimage.len() == 0 { panic_with_error!(&env, Error::InvalidPreimage); } diff --git a/soroban/contracts/htlc/src/test.rs b/soroban/contracts/htlc/src/test.rs index 3dd50cff..797db2ad 100644 --- a/soroban/contracts/htlc/src/test.rs +++ b/soroban/contracts/htlc/src/test.rs @@ -536,10 +536,9 @@ fn create_order_succeeds_for_active_registered_resolver() { assert_eq!(order_id, 1); assert_eq!(token.balance(&htlc.address), amount); - // Claim path must remain permissionless even though the registry is - // configured — the registry only gates create_order. - let outsider = Address::generate(&env); - htlc.claim_order(&order_id, &preimage, &outsider); + // A bound registry gates claims too: the active resolver that opened the + // order settles it. + htlc.claim_order(&order_id, &preimage, &resolver); let order: Order = htlc.get_order(&order_id).unwrap(); assert_eq!(order.status, OrderStatus::Claimed); } @@ -699,7 +698,7 @@ fn second_refund_fails() { sac.mint(&sender, &100_0000000); let preimage = Bytes::from_array(&env, &[31u8; 32]); - let hashlock = sha256_32(&env, &preimage); + let hashlock = hashlock_order_one(&env, &preimage); let order_id = htlc.create_order( &sender, &beneficiary, &sender, &asset, &10_0000000i128, &0i128, &hashlock, &600u64, @@ -737,9 +736,10 @@ fn refund_unknown_order_fails_not_found() { } #[test] -fn claim_by_unregistered_caller_succeeds_when_registry_configured() { - // Mirror of row 13: the registry gates creation only, so a caller - // that is not an active resolver may still reveal the preimage. +fn claim_by_unregistered_caller_fails_when_registry_configured() { + // A bound registry gates claims as well as creation, matching + // `HTLCEscrow.claimOrder`: removing a resolver stops them settling an order + // they opened while active. let env = Env::default(); env.mock_all_auths(); @@ -756,7 +756,7 @@ fn claim_by_unregistered_caller_succeeds_when_registry_configured() { registry.register(&resolver, &min_stake); let preimage = Bytes::from_array(&env, &[34u8; 32]); - let hashlock = sha256_32(&env, &preimage); + let hashlock = hashlock_order_one(&env, &preimage); let amount = 100_0000000i128; let order_id = htlc.create_order( &resolver, &beneficiary, &resolver, &asset, @@ -764,11 +764,22 @@ fn claim_by_unregistered_caller_succeeds_when_registry_configured() { ); let stranger = Address::generate(&env); - htlc.claim_order(&order_id, &preimage, &stranger); + let res = htlc.try_claim_order(&order_id, &preimage, &stranger); + assert_eq!( + res.err().unwrap().unwrap(), + Error::ClaimResolverNotRegistered.into() + ); - assert_eq!(token.balance(&beneficiary), amount); + // The order is untouched: the refund path is still available to anyone. let order: Order = htlc.get_order(&order_id).unwrap(); - assert_eq!(order.status, OrderStatus::Claimed); + assert_eq!(order.status, OrderStatus::Funded); + assert_eq!(token.balance(&beneficiary), 0); + + // The resolver that opened it can still claim. + htlc.claim_order(&order_id, &preimage, &resolver); + assert_eq!(token.balance(&beneficiary), amount); + let claimed: Order = htlc.get_order(&order_id).unwrap(); + assert_eq!(claimed.status, OrderStatus::Claimed); } #[test] @@ -792,7 +803,7 @@ fn refund_by_unregistered_caller_succeeds_when_registry_configured() { registry.register(&resolver, &min_stake); let preimage = Bytes::from_array(&env, &[33u8; 32]); - let hashlock = sha256_32(&env, &preimage); + let hashlock = hashlock_order_one(&env, &preimage); let amount = 100_0000000i128; let order_id = htlc.create_order( &resolver, &beneficiary, &resolver, &asset, diff --git a/soroban/contracts/htlc/test_snapshots/test/claim_by_unregistered_caller_fails_when_registry_configured.1.json b/soroban/contracts/htlc/test_snapshots/test/claim_by_unregistered_caller_fails_when_registry_configured.1.json new file mode 100644 index 00000000..8d6cbdf9 --- /dev/null +++ b/soroban/contracts/htlc/test_snapshots/test/claim_by_unregistered_caller_fails_when_registry_configured.1.json @@ -0,0 +1,1448 @@ +{ + "generators": { + "address": 10, + "nonce": 0 + }, + "auth": [ + [ + [ + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGWF", + { + "function": { + "contract_fn": { + "contract_address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "function_name": "set_admin", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "function_name": "initialize", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "i128": { + "hi": 0, + "lo": 0 + } + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM", + "function_name": "initialize", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + }, + { + "address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL" + }, + { + "i128": { + "hi": 0, + "lo": 1000000000 + } + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "function_name": "set_resolver_registry", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "function_name": "mint", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + }, + { + "i128": { + "hi": 0, + "lo": 6000000000 + } + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM", + "function_name": "register", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + }, + { + "i128": { + "hi": 0, + "lo": 1000000000 + } + } + ] + } + }, + "sub_invocations": [ + { + "function": { + "contract_fn": { + "contract_address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "function_name": "transfer", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + }, + { + "i128": { + "hi": 0, + "lo": 1000000000 + } + } + ] + } + }, + "sub_invocations": [] + } + ] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "function_name": "create_order", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATYON" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + }, + { + "address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL" + }, + { + "i128": { + "hi": 0, + "lo": 1000000000 + } + }, + { + "i128": { + "hi": 0, + "lo": 0 + } + }, + { + "bytes": "1f6dec255d6ef882b3f50383cd938eeb8caa66db52cb5e0c658e8f1b2f140a00" + }, + { + "u64": 600 + } + ] + } + }, + "sub_invocations": [ + { + "function": { + "contract_fn": { + "contract_address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "function_name": "transfer", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "i128": { + "hi": 0, + "lo": 1000000000 + } + } + ] + } + }, + "sub_invocations": [] + } + ] + } + ] + ], + [], + [], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "function_name": "claim_order", + "args": [ + { + "u64": 1 + }, + { + "bytes": "2222222222222222222222222222222222222222222222222222222222222222" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [] + ], + "ledger": { + "protocol_version": 22, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "account": { + "account_id": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGWF" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "account": { + "account_id": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGWF", + "balance": 0, + "seq_num": 0, + "num_sub_entries": 0, + "inflation_dest": null, + "flags": 0, + "home_domain": "", + "thresholds": "01010101", + "signers": [], + "ext": "v0" + } + }, + "ext": "v0" + }, + null + ] + ], + [ + { + "contract_data": { + "contract": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGWF", + "key": { + "ledger_key_nonce": { + "nonce": 801925984706572462 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGWF", + "key": { + "ledger_key_nonce": { + "nonce": 801925984706572462 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": 2032731177588607455 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": 2032731177588607455 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": 4837995959683129791 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": 4837995959683129791 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": 5541220902715666415 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": 5541220902715666415 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": { + "vec": [ + { + "symbol": "Order" + }, + { + "u64": 1 + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": { + "vec": [ + { + "symbol": "Order" + }, + { + "u64": 1 + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "amount" + }, + "val": { + "i128": { + "hi": 0, + "lo": 1000000000 + } + } + }, + { + "key": { + "symbol": "asset" + }, + "val": { + "address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL" + } + }, + { + "key": { + "symbol": "beneficiary" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATYON" + } + }, + { + "key": { + "symbol": "created_at" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "finalised_at" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "hashlock" + }, + "val": { + "bytes": "1f6dec255d6ef882b3f50383cd938eeb8caa66db52cb5e0c658e8f1b2f140a00" + } + }, + { + "key": { + "symbol": "id" + }, + "val": { + "u64": 1 + } + }, + { + "key": { + "symbol": "preimage" + }, + "val": { + "bytes": "2222222222222222222222222222222222222222222222222222222222222222" + } + }, + { + "key": { + "symbol": "refund_address" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + } + }, + { + "key": { + "symbol": "safety_deposit" + }, + "val": { + "i128": { + "hi": 0, + "lo": 0 + } + } + }, + { + "key": { + "symbol": "sender" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + } + }, + { + "key": { + "symbol": "status" + }, + "val": { + "u32": 1 + } + }, + { + "key": { + "symbol": "timelock" + }, + "val": { + "u64": 600 + } + } + ] + } + } + }, + "ext": "v0" + }, + 100000 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + }, + { + "key": { + "vec": [ + { + "symbol": "MinSafetyDeposit" + } + ] + }, + "val": { + "i128": { + "hi": 0, + "lo": 0 + } + } + }, + { + "key": { + "vec": [ + { + "symbol": "NextOrderId" + } + ] + }, + "val": { + "u64": 2 + } + }, + { + "key": { + "vec": [ + { + "symbol": "ResolverRegistry" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 100000 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", + "key": { + "ledger_key_nonce": { + "nonce": 1033654523790656264 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", + "key": { + "ledger_key_nonce": { + "nonce": 1033654523790656264 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM", + "key": { + "vec": [ + { + "symbol": "Resolver" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM", + "key": { + "vec": [ + { + "symbol": "Resolver" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "active" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "address" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + } + }, + { + "key": { + "symbol": "last_slash_at" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "registered_at" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "stake" + }, + "val": { + "i128": { + "hi": 0, + "lo": 1000000000 + } + } + }, + { + "key": { + "symbol": "total_slashed" + }, + "val": { + "i128": { + "hi": 0, + "lo": 0 + } + } + } + ] + } + } + }, + "ext": "v0" + }, + 100000 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + }, + { + "key": { + "vec": [ + { + "symbol": "MinStake" + } + ] + }, + "val": { + "i128": { + "hi": 0, + "lo": 1000000000 + } + } + }, + { + "key": { + "vec": [ + { + "symbol": "ResolverList" + } + ] + }, + "val": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + } + ] + } + }, + { + "key": { + "vec": [ + { + "symbol": "SlashBeneficiary" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + } + }, + { + "key": { + "vec": [ + { + "symbol": "StakeAsset" + } + ] + }, + "val": { + "address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 100000 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5", + "key": { + "ledger_key_nonce": { + "nonce": 4270020994084947596 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5", + "key": { + "ledger_key_nonce": { + "nonce": 4270020994084947596 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5", + "key": { + "ledger_key_nonce": { + "nonce": 5806905060045992000 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5", + "key": { + "ledger_key_nonce": { + "nonce": 5806905060045992000 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5", + "key": { + "ledger_key_nonce": { + "nonce": 8370022561469687789 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5", + "key": { + "ledger_key_nonce": { + "nonce": 8370022561469687789 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "amount" + }, + "val": { + "i128": { + "hi": 0, + "lo": 0 + } + } + }, + { + "key": { + "symbol": "authorized" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "clawback" + }, + "val": { + "bool": false + } + } + ] + } + } + }, + "ext": "v0" + }, + 518400 + ] + ], + [ + { + "contract_data": { + "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "amount" + }, + "val": { + "i128": { + "hi": 0, + "lo": 1000000000 + } + } + }, + { + "key": { + "symbol": "authorized" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "clawback" + }, + "val": { + "bool": false + } + } + ] + } + } + }, + "ext": "v0" + }, + 518400 + ] + ], + [ + { + "contract_data": { + "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "amount" + }, + "val": { + "i128": { + "hi": 0, + "lo": 4000000000 + } + } + }, + { + "key": { + "symbol": "authorized" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "clawback" + }, + "val": { + "bool": false + } + } + ] + } + } + }, + "ext": "v0" + }, + 518400 + ] + ], + [ + { + "contract_data": { + "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATYON" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATYON" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "amount" + }, + "val": { + "i128": { + "hi": 0, + "lo": 1000000000 + } + } + }, + { + "key": { + "symbol": "authorized" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "clawback" + }, + "val": { + "bool": false + } + } + ] + } + } + }, + "ext": "v0" + }, + 518400 + ] + ], + [ + { + "contract_data": { + "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": "stellar_asset", + "storage": [ + { + "key": { + "symbol": "METADATA" + }, + "val": { + "map": [ + { + "key": { + "symbol": "decimal" + }, + "val": { + "u32": 7 + } + }, + { + "key": { + "symbol": "name" + }, + "val": { + "string": "aaa:GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGWF" + } + }, + { + "key": { + "symbol": "symbol" + }, + "val": { + "string": "aaa" + } + } + ] + } + }, + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + }, + { + "key": { + "vec": [ + { + "symbol": "AssetInfo" + } + ] + }, + "val": { + "vec": [ + { + "symbol": "AlphaNum4" + }, + { + "map": [ + { + "key": { + "symbol": "asset_code" + }, + "val": { + "string": "aaa\\0" + } + }, + { + "key": { + "symbol": "issuer" + }, + "val": { + "bytes": "0000000000000000000000000000000000000000000000000000000000000002" + } + } + ] + } + ] + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 120960 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 100000 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/soroban/contracts/htlc/test_snapshots/test/claim_unknown_order_fails_not_found.1.json b/soroban/contracts/htlc/test_snapshots/test/claim_unknown_order_fails_not_found.1.json new file mode 100644 index 00000000..f6c0e3da --- /dev/null +++ b/soroban/contracts/htlc/test_snapshots/test/claim_unknown_order_fails_not_found.1.json @@ -0,0 +1,174 @@ +{ + "generators": { + "address": 3, + "nonce": 0 + }, + "auth": [ + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "initialize", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + }, + { + "i128": { + "hi": 0, + "lo": 0 + } + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [] + ], + "ledger": { + "protocol_version": 22, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": 801925984706572462 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": 801925984706572462 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + }, + { + "key": { + "vec": [ + { + "symbol": "MinSafetyDeposit" + } + ] + }, + "val": { + "i128": { + "hi": 0, + "lo": 0 + } + } + }, + { + "key": { + "vec": [ + { + "symbol": "NextOrderId" + } + ] + }, + "val": { + "u64": 1 + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 100000 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 100000 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/soroban/contracts/htlc/test_snapshots/test/create_order_succeeds_for_active_registered_resolver.1.json b/soroban/contracts/htlc/test_snapshots/test/create_order_succeeds_for_active_registered_resolver.1.json index 65c3b1a0..7fdd5a58 100644 --- a/soroban/contracts/htlc/test_snapshots/test/create_order_succeeds_for_active_registered_resolver.1.json +++ b/soroban/contracts/htlc/test_snapshots/test/create_order_succeeds_for_active_registered_resolver.1.json @@ -1,6 +1,6 @@ { "generators": { - "address": 10, + "address": 9, "nonce": 0 }, "auth": [ @@ -248,7 +248,7 @@ [], [ [ - "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVAX5", + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5", { "function": { "contract_fn": { @@ -262,7 +262,7 @@ "bytes": "2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a" }, { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVAX5" + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" } ] } @@ -955,7 +955,7 @@ "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5", "key": { "ledger_key_nonce": { - "nonce": 8370022561469687789 + "nonce": 6277191135259896685 } }, "durability": "temporary" @@ -970,7 +970,7 @@ "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5", "key": { "ledger_key_nonce": { - "nonce": 8370022561469687789 + "nonce": 6277191135259896685 } }, "durability": "temporary", @@ -985,10 +985,10 @@ [ { "contract_data": { - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVAX5", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5", "key": { "ledger_key_nonce": { - "nonce": 6277191135259896685 + "nonce": 8370022561469687789 } }, "durability": "temporary" @@ -1000,10 +1000,10 @@ "data": { "contract_data": { "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVAX5", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5", "key": { "ledger_key_nonce": { - "nonce": 6277191135259896685 + "nonce": 8370022561469687789 } }, "durability": "temporary", diff --git a/soroban/contracts/htlc/test_snapshots/test/refund_by_unregistered_caller_succeeds_when_registry_configured.1.json b/soroban/contracts/htlc/test_snapshots/test/refund_by_unregistered_caller_succeeds_when_registry_configured.1.json new file mode 100644 index 00000000..e83c2385 --- /dev/null +++ b/soroban/contracts/htlc/test_snapshots/test/refund_by_unregistered_caller_succeeds_when_registry_configured.1.json @@ -0,0 +1,1370 @@ +{ + "generators": { + "address": 10, + "nonce": 0 + }, + "auth": [ + [ + [ + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGWF", + { + "function": { + "contract_fn": { + "contract_address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "function_name": "set_admin", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "function_name": "initialize", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "i128": { + "hi": 0, + "lo": 0 + } + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM", + "function_name": "initialize", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + }, + { + "address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL" + }, + { + "i128": { + "hi": 0, + "lo": 1000000000 + } + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "function_name": "set_resolver_registry", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "function_name": "mint", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + }, + { + "i128": { + "hi": 0, + "lo": 6000000000 + } + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM", + "function_name": "register", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + }, + { + "i128": { + "hi": 0, + "lo": 1000000000 + } + } + ] + } + }, + "sub_invocations": [ + { + "function": { + "contract_fn": { + "contract_address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "function_name": "transfer", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + }, + { + "i128": { + "hi": 0, + "lo": 1000000000 + } + } + ] + } + }, + "sub_invocations": [] + } + ] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "function_name": "create_order", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATYON" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + }, + { + "address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL" + }, + { + "i128": { + "hi": 0, + "lo": 1000000000 + } + }, + { + "i128": { + "hi": 0, + "lo": 0 + } + }, + { + "bytes": "ca38a513537cb49fc744147bbafb49a5c1f0699e8e517511d785bbe6c83a9c8b" + }, + { + "u64": 600 + } + ] + } + }, + "sub_invocations": [ + { + "function": { + "contract_fn": { + "contract_address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "function_name": "transfer", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "i128": { + "hi": 0, + "lo": 1000000000 + } + } + ] + } + }, + "sub_invocations": [] + } + ] + } + ] + ], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVAX5", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "function_name": "refund_order", + "args": [ + { + "u64": 1 + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVAX5" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [] + ], + "ledger": { + "protocol_version": 22, + "sequence_number": 1, + "timestamp": 601, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "account": { + "account_id": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGWF" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "account": { + "account_id": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGWF", + "balance": 0, + "seq_num": 0, + "num_sub_entries": 0, + "inflation_dest": null, + "flags": 0, + "home_domain": "", + "thresholds": "01010101", + "signers": [], + "ext": "v0" + } + }, + "ext": "v0" + }, + null + ] + ], + [ + { + "contract_data": { + "contract": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGWF", + "key": { + "ledger_key_nonce": { + "nonce": 801925984706572462 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGWF", + "key": { + "ledger_key_nonce": { + "nonce": 801925984706572462 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": 2032731177588607455 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": 2032731177588607455 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": 4837995959683129791 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": 4837995959683129791 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": 5541220902715666415 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": 5541220902715666415 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": { + "vec": [ + { + "symbol": "Order" + }, + { + "u64": 1 + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": { + "vec": [ + { + "symbol": "Order" + }, + { + "u64": 1 + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "amount" + }, + "val": { + "i128": { + "hi": 0, + "lo": 1000000000 + } + } + }, + { + "key": { + "symbol": "asset" + }, + "val": { + "address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL" + } + }, + { + "key": { + "symbol": "beneficiary" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATYON" + } + }, + { + "key": { + "symbol": "created_at" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "finalised_at" + }, + "val": { + "u64": 601 + } + }, + { + "key": { + "symbol": "hashlock" + }, + "val": { + "bytes": "ca38a513537cb49fc744147bbafb49a5c1f0699e8e517511d785bbe6c83a9c8b" + } + }, + { + "key": { + "symbol": "id" + }, + "val": { + "u64": 1 + } + }, + { + "key": { + "symbol": "preimage" + }, + "val": { + "bytes": "" + } + }, + { + "key": { + "symbol": "refund_address" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + } + }, + { + "key": { + "symbol": "safety_deposit" + }, + "val": { + "i128": { + "hi": 0, + "lo": 0 + } + } + }, + { + "key": { + "symbol": "sender" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + } + }, + { + "key": { + "symbol": "status" + }, + "val": { + "u32": 2 + } + }, + { + "key": { + "symbol": "timelock" + }, + "val": { + "u64": 600 + } + } + ] + } + } + }, + "ext": "v0" + }, + 100000 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + }, + { + "key": { + "vec": [ + { + "symbol": "MinSafetyDeposit" + } + ] + }, + "val": { + "i128": { + "hi": 0, + "lo": 0 + } + } + }, + { + "key": { + "vec": [ + { + "symbol": "NextOrderId" + } + ] + }, + "val": { + "u64": 2 + } + }, + { + "key": { + "vec": [ + { + "symbol": "ResolverRegistry" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 100000 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", + "key": { + "ledger_key_nonce": { + "nonce": 1033654523790656264 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", + "key": { + "ledger_key_nonce": { + "nonce": 1033654523790656264 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM", + "key": { + "vec": [ + { + "symbol": "Resolver" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM", + "key": { + "vec": [ + { + "symbol": "Resolver" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "active" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "address" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + } + }, + { + "key": { + "symbol": "last_slash_at" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "registered_at" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "stake" + }, + "val": { + "i128": { + "hi": 0, + "lo": 1000000000 + } + } + }, + { + "key": { + "symbol": "total_slashed" + }, + "val": { + "i128": { + "hi": 0, + "lo": 0 + } + } + } + ] + } + } + }, + "ext": "v0" + }, + 100000 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + }, + { + "key": { + "vec": [ + { + "symbol": "MinStake" + } + ] + }, + "val": { + "i128": { + "hi": 0, + "lo": 1000000000 + } + } + }, + { + "key": { + "vec": [ + { + "symbol": "ResolverList" + } + ] + }, + "val": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + } + ] + } + }, + { + "key": { + "vec": [ + { + "symbol": "SlashBeneficiary" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + } + }, + { + "key": { + "vec": [ + { + "symbol": "StakeAsset" + } + ] + }, + "val": { + "address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 100000 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5", + "key": { + "ledger_key_nonce": { + "nonce": 4270020994084947596 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5", + "key": { + "ledger_key_nonce": { + "nonce": 4270020994084947596 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5", + "key": { + "ledger_key_nonce": { + "nonce": 8370022561469687789 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5", + "key": { + "ledger_key_nonce": { + "nonce": 8370022561469687789 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVAX5", + "key": { + "ledger_key_nonce": { + "nonce": 6277191135259896685 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVAX5", + "key": { + "ledger_key_nonce": { + "nonce": 6277191135259896685 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6312000 + ] + ], + [ + { + "contract_data": { + "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "amount" + }, + "val": { + "i128": { + "hi": 0, + "lo": 0 + } + } + }, + { + "key": { + "symbol": "authorized" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "clawback" + }, + "val": { + "bool": false + } + } + ] + } + } + }, + "ext": "v0" + }, + 518400 + ] + ], + [ + { + "contract_data": { + "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "amount" + }, + "val": { + "i128": { + "hi": 0, + "lo": 1000000000 + } + } + }, + { + "key": { + "symbol": "authorized" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "clawback" + }, + "val": { + "bool": false + } + } + ] + } + } + }, + "ext": "v0" + }, + 518400 + ] + ], + [ + { + "contract_data": { + "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "amount" + }, + "val": { + "i128": { + "hi": 0, + "lo": 5000000000 + } + } + }, + { + "key": { + "symbol": "authorized" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "clawback" + }, + "val": { + "bool": false + } + } + ] + } + } + }, + "ext": "v0" + }, + 518400 + ] + ], + [ + { + "contract_data": { + "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": "stellar_asset", + "storage": [ + { + "key": { + "symbol": "METADATA" + }, + "val": { + "map": [ + { + "key": { + "symbol": "decimal" + }, + "val": { + "u32": 7 + } + }, + { + "key": { + "symbol": "name" + }, + "val": { + "string": "aaa:GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGWF" + } + }, + { + "key": { + "symbol": "symbol" + }, + "val": { + "string": "aaa" + } + } + ] + } + }, + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + }, + { + "key": { + "vec": [ + { + "symbol": "AssetInfo" + } + ] + }, + "val": { + "vec": [ + { + "symbol": "AlphaNum4" + }, + { + "map": [ + { + "key": { + "symbol": "asset_code" + }, + "val": { + "string": "aaa\\0" + } + }, + { + "key": { + "symbol": "issuer" + }, + "val": { + "bytes": "0000000000000000000000000000000000000000000000000000000000000002" + } + } + ] + } + ] + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 120960 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 100000 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/soroban/contracts/htlc/test_snapshots/test/refund_unknown_order_fails_not_found.1.json b/soroban/contracts/htlc/test_snapshots/test/refund_unknown_order_fails_not_found.1.json new file mode 100644 index 00000000..f6c0e3da --- /dev/null +++ b/soroban/contracts/htlc/test_snapshots/test/refund_unknown_order_fails_not_found.1.json @@ -0,0 +1,174 @@ +{ + "generators": { + "address": 3, + "nonce": 0 + }, + "auth": [ + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "initialize", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + }, + { + "i128": { + "hi": 0, + "lo": 0 + } + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [] + ], + "ledger": { + "protocol_version": 22, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": 801925984706572462 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": 801925984706572462 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + }, + { + "key": { + "vec": [ + { + "symbol": "MinSafetyDeposit" + } + ] + }, + "val": { + "i128": { + "hi": 0, + "lo": 0 + } + } + }, + { + "key": { + "vec": [ + { + "symbol": "NextOrderId" + } + ] + }, + "val": { + "u64": 1 + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 100000 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 100000 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/soroban/contracts/htlc/test_snapshots/test/second_refund_fails.1.json b/soroban/contracts/htlc/test_snapshots/test/second_refund_fails.1.json new file mode 100644 index 00000000..7128d556 --- /dev/null +++ b/soroban/contracts/htlc/test_snapshots/test/second_refund_fails.1.json @@ -0,0 +1,884 @@ +{ + "generators": { + "address": 7, + "nonce": 0 + }, + "auth": [ + [ + [ + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGWF", + { + "function": { + "contract_fn": { + "contract_address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "function_name": "set_admin", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "function_name": "initialize", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "i128": { + "hi": 0, + "lo": 0 + } + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "function_name": "mint", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + }, + { + "i128": { + "hi": 0, + "lo": 1000000000 + } + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "function_name": "create_order", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + }, + { + "address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL" + }, + { + "i128": { + "hi": 0, + "lo": 100000000 + } + }, + { + "i128": { + "hi": 0, + "lo": 0 + } + }, + { + "bytes": "8c215ac0685a2f67f2e0b8846eda044a0c7d9aa3075e43136de3f9c823f1f1f4" + }, + { + "u64": 600 + } + ] + } + }, + "sub_invocations": [ + { + "function": { + "contract_fn": { + "contract_address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "function_name": "transfer", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "i128": { + "hi": 0, + "lo": 100000000 + } + } + ] + } + }, + "sub_invocations": [] + } + ] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "function_name": "refund_order", + "args": [ + { + "u64": 1 + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [] + ], + "ledger": { + "protocol_version": 22, + "sequence_number": 1, + "timestamp": 601, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "account": { + "account_id": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGWF" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "account": { + "account_id": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGWF", + "balance": 0, + "seq_num": 0, + "num_sub_entries": 0, + "inflation_dest": null, + "flags": 0, + "home_domain": "", + "thresholds": "01010101", + "signers": [], + "ext": "v0" + } + }, + "ext": "v0" + }, + null + ] + ], + [ + { + "contract_data": { + "contract": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGWF", + "key": { + "ledger_key_nonce": { + "nonce": 801925984706572462 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGWF", + "key": { + "ledger_key_nonce": { + "nonce": 801925984706572462 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": 1033654523790656264 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": 1033654523790656264 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": 5541220902715666415 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": 5541220902715666415 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": { + "vec": [ + { + "symbol": "Order" + }, + { + "u64": 1 + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": { + "vec": [ + { + "symbol": "Order" + }, + { + "u64": 1 + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "amount" + }, + "val": { + "i128": { + "hi": 0, + "lo": 100000000 + } + } + }, + { + "key": { + "symbol": "asset" + }, + "val": { + "address": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL" + } + }, + { + "key": { + "symbol": "beneficiary" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + } + }, + { + "key": { + "symbol": "created_at" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "finalised_at" + }, + "val": { + "u64": 601 + } + }, + { + "key": { + "symbol": "hashlock" + }, + "val": { + "bytes": "8c215ac0685a2f67f2e0b8846eda044a0c7d9aa3075e43136de3f9c823f1f1f4" + } + }, + { + "key": { + "symbol": "id" + }, + "val": { + "u64": 1 + } + }, + { + "key": { + "symbol": "preimage" + }, + "val": { + "bytes": "" + } + }, + { + "key": { + "symbol": "refund_address" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + }, + { + "key": { + "symbol": "safety_deposit" + }, + "val": { + "i128": { + "hi": 0, + "lo": 0 + } + } + }, + { + "key": { + "symbol": "sender" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + }, + { + "key": { + "symbol": "status" + }, + "val": { + "u32": 2 + } + }, + { + "key": { + "symbol": "timelock" + }, + "val": { + "u64": 600 + } + } + ] + } + } + }, + "ext": "v0" + }, + 100000 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + }, + { + "key": { + "vec": [ + { + "symbol": "MinSafetyDeposit" + } + ] + }, + "val": { + "i128": { + "hi": 0, + "lo": 0 + } + } + }, + { + "key": { + "vec": [ + { + "symbol": "NextOrderId" + } + ] + }, + "val": { + "u64": 2 + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 100000 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", + "key": { + "ledger_key_nonce": { + "nonce": 4837995959683129791 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", + "key": { + "ledger_key_nonce": { + "nonce": 4837995959683129791 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM", + "key": { + "ledger_key_nonce": { + "nonce": 2032731177588607455 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM", + "key": { + "ledger_key_nonce": { + "nonce": 2032731177588607455 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6312000 + ] + ], + [ + { + "contract_data": { + "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "amount" + }, + "val": { + "i128": { + "hi": 0, + "lo": 0 + } + } + }, + { + "key": { + "symbol": "authorized" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "clawback" + }, + "val": { + "bool": false + } + } + ] + } + } + }, + "ext": "v0" + }, + 518400 + ] + ], + [ + { + "contract_data": { + "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "amount" + }, + "val": { + "i128": { + "hi": 0, + "lo": 1000000000 + } + } + }, + { + "key": { + "symbol": "authorized" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "clawback" + }, + "val": { + "bool": false + } + } + ] + } + } + }, + "ext": "v0" + }, + 518400 + ] + ], + [ + { + "contract_data": { + "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": "stellar_asset", + "storage": [ + { + "key": { + "symbol": "METADATA" + }, + "val": { + "map": [ + { + "key": { + "symbol": "decimal" + }, + "val": { + "u32": 7 + } + }, + { + "key": { + "symbol": "name" + }, + "val": { + "string": "aaa:GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGWF" + } + }, + { + "key": { + "symbol": "symbol" + }, + "val": { + "string": "aaa" + } + } + ] + } + }, + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + }, + { + "key": { + "vec": [ + { + "symbol": "AssetInfo" + } + ] + }, + "val": { + "vec": [ + { + "symbol": "AlphaNum4" + }, + { + "map": [ + { + "key": { + "symbol": "asset_code" + }, + "val": { + "string": "aaa\\0" + } + }, + { + "key": { + "symbol": "issuer" + }, + "val": { + "bytes": "0000000000000000000000000000000000000000000000000000000000000002" + } + } + ] + } + ] + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 120960 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 100000 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file