Skip to content

Commit 227d533

Browse files
committed
fix(scenarios): make Synapse source runs consumer-equivalent
Install only the production dependency closure and resolve peers under Synapse's pnpm security policy. Add EIP-2612 support to MockUSDFC so fresh devnet accounts can fund uploads through Synapse. #182 didn't quite work in resolving #179
1 parent d059da1 commit 227d533

6 files changed

Lines changed: 165 additions & 33 deletions

File tree

ci/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ override is applied. The resolver does not infer overrides from package metadata
192192

193193
Overrides are currently allowed only for npm-installed `synapse-sdk` and
194194
`filecoin-pin` selections. They are written to the temporary consumer
195-
`package.json`; source profiles use the checkout's committed lockfile.
195+
`package.json`.
196196

197197
Current consumers write npm overrides to the temporary `package.json` used by
198198
their scenario.

contracts/MockUSDFC/src/MockUSDFC.sol

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,20 @@
22
pragma solidity ^0.8.20;
33

44
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
5+
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
56

67
/**
78
* @title MockUSDFC
89
* @dev Mock USDC token for testing FOC warm storage services
910
*/
10-
contract MockUSDFC is ERC20 {
11+
contract MockUSDFC is ERC20, ERC20Permit {
1112
uint8 private _decimals;
1213

1314
/**
1415
* @dev Constructor that gives msg.sender all of initial supply.
1516
* @param initialSupply The initial supply of tokens (in wei, accounting for decimals)
1617
*/
17-
constructor(uint256 initialSupply) ERC20("Mock USDC", "USDFC") {
18+
constructor(uint256 initialSupply) ERC20("Mock USDC", "USDFC") ERC20Permit("Mock USDC") {
1819
_decimals = 18;
1920
_mint(msg.sender, initialSupply);
2021
}
@@ -26,6 +27,10 @@ contract MockUSDFC is ERC20 {
2627
return _decimals;
2728
}
2829

30+
function version() external pure returns (string memory) {
31+
return "1";
32+
}
33+
2934
/**
3035
* @dev Mint new tokens (for testing purposes)
3136
* @param to Address to receive the tokens
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.20;
3+
4+
import "forge-std/Test.sol";
5+
import "../src/MockUSDFC.sol";
6+
7+
contract MockUSDFCTest is Test {
8+
bytes32 private constant PERMIT_TYPEHASH =
9+
keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
10+
11+
MockUSDFC private token;
12+
uint256 private ownerKey;
13+
address private owner;
14+
15+
function setUp() public {
16+
token = new MockUSDFC(0);
17+
ownerKey = 0xA11CE;
18+
owner = vm.addr(ownerKey);
19+
token.mint(owner, 100 ether);
20+
}
21+
22+
function testPermit() public {
23+
address spender = makeAddr("spender");
24+
uint256 value = 25 ether;
25+
uint256 deadline = block.timestamp + 1 hours;
26+
bytes32 structHash = keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, 0, deadline));
27+
bytes32 digest = keccak256(abi.encodePacked("\x19\x01", token.DOMAIN_SEPARATOR(), structHash));
28+
(uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerKey, digest);
29+
30+
token.permit(owner, spender, value, deadline, v, r, s);
31+
32+
assertEq(token.allowance(owner, spender), value);
33+
assertEq(token.nonces(owner), 1);
34+
assertEq(token.version(), "1");
35+
}
36+
}

scenarios/synapse-e2e/source-runtime.mjs

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
/**
22
* Runs foc-devnet scenarios against Synapse TypeScript source on Node 24+.
33
*
4-
* Source profiles install Synapse's production dependency closure, then preload
5-
* this module with `node --import`. Public `@filoz/synapse-sdk` and
6-
* `@filoz/synapse-core` imports resolve to their source counterparts instead of
7-
* the packages' compiled `dist` targets. All other imports use Node's normal
8-
* resolver.
4+
* Source profiles install Synapse's production dependencies and peer runtime,
5+
* then preload this module with `node --import`. Public
6+
* `@filoz/synapse-sdk` and `@filoz/synapse-core` imports resolve to their source
7+
* counterparts instead of the packages' compiled `dist` targets. Peer imports
8+
* resolve from the temporary consumer; all other imports use Node's resolver.
99
*
1010
* Mappings come from each package's export map, keeping the scenario on the
1111
* public API and avoiding a hard-coded list that drifts as exports change.
@@ -16,10 +16,14 @@ import assert from 'node:assert/strict'
1616
import { existsSync, readFileSync } from 'node:fs'
1717
import { registerHooks } from 'node:module'
1818
import { dirname, join, resolve } from 'node:path'
19-
import { pathToFileURL } from 'node:url'
19+
import { fileURLToPath, pathToFileURL } from 'node:url'
2020

2121
const sourceRoot = process.env.SYNAPSE_SDK_SOURCE_DIR
2222
assert(sourceRoot, 'SYNAPSE_SDK_SOURCE_DIR must name the Synapse checkout when using source-runtime.mjs')
23+
const runtimePackageUrl = pathToFileURL(join(dirname(fileURLToPath(import.meta.url)), 'package.json')).href
24+
const runtimeDependencies = new Set(
25+
Object.keys(JSON.parse(readFileSync(fileURLToPath(runtimePackageUrl), 'utf8')).dependencies ?? {})
26+
)
2327

2428
// Export entries may be strings or nested condition objects. Prefer the
2529
// conditions used by this ESM runtime, then inspect package-specific branches.
@@ -62,10 +66,20 @@ const sourceMappings = new Map([
6266
...sourceExports('@filoz/synapse-core', 'packages/synapse-core'),
6367
])
6468

65-
// Short-circuit exact public package matches; delegate everything else.
69+
function packageName(specifier) {
70+
if (specifier.startsWith('@')) return specifier.split('/', 2).join('/')
71+
return specifier.split('/', 1)[0]
72+
}
73+
74+
// Short-circuit public Synapse exports and resolve peer dependencies from the
75+
// temporary consumer. Source package dependencies use Node's normal resolver.
6676
registerHooks({
6777
resolve(specifier, context, nextResolve) {
6878
const sourceUrl = sourceMappings.get(specifier)
69-
return sourceUrl == null ? nextResolve(specifier, context) : { url: sourceUrl, shortCircuit: true }
79+
if (sourceUrl != null) return { url: sourceUrl, shortCircuit: true }
80+
if (runtimeDependencies.has(packageName(specifier))) {
81+
return nextResolve(specifier, { ...context, parentURL: runtimePackageUrl })
82+
}
83+
return nextResolve(specifier, context)
7084
},
7185
})

scenarios/synapse_runtime.py

Lines changed: 53 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,32 @@ def _write_manifest(work_dir: Path, dependency: dict) -> None:
110110
(work_dir / "package.json").write_text(json.dumps(manifest, indent=2) + "\n")
111111

112112

113+
def _write_source_manifest(work_dir: Path, source_dir: Path) -> None:
114+
dependencies: dict[str, str] = {}
115+
for package in ("synapse-sdk", "synapse-core"):
116+
package_json = json.loads(
117+
(source_dir / "packages" / package / "package.json").read_text()
118+
)
119+
for name, version in package_json.get("peerDependencies", {}).items():
120+
existing = dependencies.get(name)
121+
if existing is not None and existing != version:
122+
raise RuntimeError(
123+
f"Synapse source packages disagree on {name}: {existing} != {version}"
124+
)
125+
dependencies[name] = version
126+
manifest = {
127+
"name": "foc-devnet-synapse-source-e2e",
128+
"private": True,
129+
"type": "module",
130+
"dependencies": dependencies,
131+
}
132+
(work_dir / "package.json").write_text(json.dumps(manifest, indent=2) + "\n")
133+
policy = source_dir / "pnpm-workspace.yaml"
134+
if not policy.is_file():
135+
raise RuntimeError(f"Synapse source has no pnpm workspace policy: {policy}")
136+
shutil.copyfile(policy, work_dir / policy.name)
137+
138+
113139
def _copy_scenarios(work_dir: Path) -> None:
114140
source = _scenario_dir()
115141
if not source.is_dir():
@@ -126,6 +152,11 @@ def _source_pnpm_version(source_dir: Path) -> str:
126152
return package_manager.removeprefix("pnpm@")
127153

128154

155+
def _has_source_package_closure(source_dir: Path) -> bool:
156+
node_modules = source_dir / "packages" / "synapse-sdk" / "node_modules"
157+
return (node_modules / "@filoz" / "synapse-core").is_dir()
158+
159+
129160
def _source_commit(source_dir: Path) -> str:
130161
result = subprocess.run(
131162
["git", "-C", str(source_dir), "rev-parse", "HEAD"],
@@ -197,34 +228,41 @@ def prepare_synapse_runtime(work_dir: Path) -> SynapseRuntime:
197228

198229
pnpm_version = _source_pnpm_version(source_dir)
199230
source_node_modules = source_dir / "packages" / "synapse-sdk" / "node_modules"
200-
has_runtime_closure = (source_node_modules / "viem").is_dir() and (
201-
source_node_modules / "@filoz" / "synapse-core"
202-
).is_dir()
203-
if not local_source or not has_runtime_closure:
231+
has_package_closure = _has_source_package_closure(source_dir)
232+
if not local_source or not has_package_closure:
204233
if not run_cmd(
205234
[
206235
"pnpm",
207236
"install",
208-
"--frozen-lockfile",
237+
"--no-frozen-lockfile",
209238
"--prod",
210239
"--ignore-scripts",
211240
"--filter",
212241
"@filoz/synapse-sdk...",
213242
],
214243
cwd=str(source_dir),
215-
label=f"install Synapse production runtime (pnpm@{pnpm_version})",
244+
label=f"install Synapse production dependencies (pnpm@{pnpm_version})",
216245
):
217-
raise RuntimeError("failed to install Synapse production runtime")
218-
if not source_node_modules.is_dir():
219-
raise RuntimeError(
220-
f"Synapse production install has no SDK node_modules: {source_node_modules}"
221-
)
222-
runtime_node_modules = work_dir / "node_modules"
223-
if runtime_node_modules.exists() or runtime_node_modules.is_symlink():
246+
raise RuntimeError("failed to install Synapse production dependencies")
247+
if not _has_source_package_closure(source_dir):
224248
raise RuntimeError(
225-
f"Synapse runtime node_modules already exists: {runtime_node_modules}"
249+
f"Synapse source install has no package closure: {source_node_modules}"
226250
)
227-
runtime_node_modules.symlink_to(source_node_modules, target_is_directory=True)
251+
_write_source_manifest(work_dir, source_dir)
252+
if not run_cmd(
253+
[
254+
"pnpm",
255+
"install",
256+
"--no-frozen-lockfile",
257+
"--prod",
258+
"--ignore-scripts",
259+
],
260+
cwd=str(work_dir),
261+
label="install Synapse peer runtime",
262+
):
263+
raise RuntimeError("failed to install Synapse peer runtime")
264+
if not (work_dir / "node_modules" / "viem").is_dir():
265+
raise RuntimeError("Synapse peer runtime has no viem installation")
228266
provenance = f"{provenance} (pnpm@{pnpm_version})"
229267
runtime = SynapseRuntime(work_dir, "source", provenance, source_dir)
230268
else:

scripts/tests/test_scenario_dependencies.py

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -149,18 +149,32 @@ def test_npm_runtime_resolves_fallback_consumer_dependencies(
149149
"commit": "deadbeef",
150150
},
151151
)
152-
def test_source_runtime_checks_out_and_installs_production_closure(
152+
def test_source_runtime_installs_production_and_peer_closures(
153153
self, _component, run_cmd, _source_commit, _copy_scenarios
154154
):
155155
with tempfile.TemporaryDirectory() as directory:
156156
source = Path(directory) / "synapse-sdk"
157157
source.mkdir()
158-
(source / "packages" / "synapse-sdk" / "node_modules").mkdir(parents=True)
158+
source_node_modules = source / "packages" / "synapse-sdk" / "node_modules"
159+
(source_node_modules / "@filoz" / "synapse-core").mkdir(parents=True)
160+
(Path(directory) / "node_modules" / "viem").mkdir(parents=True)
159161
(source / "package.json").write_text('{"packageManager":"pnpm@11.5.3"}')
162+
(source / "pnpm-workspace.yaml").write_text(
163+
"minimumReleaseAge: 10080\ntrustPolicy: no-downgrade\n"
164+
)
165+
for package in ("synapse-sdk", "synapse-core"):
166+
package_dir = source / "packages" / package
167+
package_dir.mkdir(parents=True, exist_ok=True)
168+
(package_dir / "package.json").write_text(
169+
'{"peerDependencies":{"viem":"2.x"}}'
170+
)
160171
runtime = prepare_synapse_runtime(Path(directory))
172+
self.assertFalse((runtime.work_dir / "node_modules").is_symlink())
161173
self.assertEqual(
162-
(runtime.work_dir / "node_modules").resolve(),
163-
source / "packages" / "synapse-sdk" / "node_modules",
174+
json.loads((runtime.work_dir / "package.json").read_text())[
175+
"dependencies"
176+
],
177+
{"viem": "2.x"},
164178
)
165179

166180
commands = [call.args[0] for call in run_cmd.call_args_list]
@@ -169,14 +183,24 @@ def test_source_runtime_checks_out_and_installs_production_closure(
169183
[
170184
"pnpm",
171185
"install",
172-
"--frozen-lockfile",
186+
"--no-frozen-lockfile",
173187
"--prod",
174188
"--ignore-scripts",
175189
"--filter",
176190
"@filoz/synapse-sdk...",
177191
],
178192
commands,
179193
)
194+
self.assertIn(
195+
[
196+
"pnpm",
197+
"install",
198+
"--no-frozen-lockfile",
199+
"--prod",
200+
"--ignore-scripts",
201+
],
202+
commands,
203+
)
180204

181205
@patch("scenarios.synapse_runtime._copy_scenarios")
182206
@patch("scenarios.synapse_runtime._source_commit", return_value="localcommit")
@@ -199,9 +223,18 @@ def test_local_source_runtime_uses_declared_pnpm(
199223
source_node_modules = (
200224
source_dir / "packages" / "synapse-sdk" / "node_modules"
201225
)
202-
(source_node_modules / "viem").mkdir(parents=True)
203226
(source_node_modules / "@filoz" / "synapse-core").mkdir(parents=True)
227+
(work_dir / "node_modules" / "viem").mkdir(parents=True)
204228
(source_dir / "package.json").write_text('{"packageManager":"pnpm@11.5.3"}')
229+
(source_dir / "pnpm-workspace.yaml").write_text(
230+
"minimumReleaseAge: 10080\ntrustPolicy: no-downgrade\n"
231+
)
232+
for package in ("synapse-sdk", "synapse-core"):
233+
package_dir = source_dir / "packages" / package
234+
package_dir.mkdir(parents=True, exist_ok=True)
235+
(package_dir / "package.json").write_text(
236+
'{"peerDependencies":{"viem":"2.x"}}'
237+
)
205238
with patch.dict("os.environ", {"SYNAPSE_SDK_SOURCE_DIR": str(source_dir)}):
206239
runtime = prepare_synapse_runtime(work_dir)
207240

@@ -210,7 +243,13 @@ def test_local_source_runtime_uses_declared_pnpm(
210243
)
211244
commands = [call.args[0] for call in run_cmd.call_args_list]
212245
self.assertFalse(any(command[:2] == ["git", "clone"] for command in commands))
213-
self.assertFalse(any(command[0] == "pnpm" for command in commands))
246+
source_commands = [
247+
call.args[0]
248+
for call in run_cmd.call_args_list
249+
if call.kwargs.get("cwd") == str(source_dir)
250+
]
251+
self.assertFalse(any(command[0] == "pnpm" for command in source_commands))
252+
self.assertTrue(any(command[:2] == ["pnpm", "install"] for command in commands))
214253

215254
@patch("scenarios.synapse_runtime.ok")
216255
@patch("scenarios.synapse_runtime.info")

0 commit comments

Comments
 (0)