diff --git a/CHANGELOG.md b/CHANGELOG.md index a0dfd10..eaf3a0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,14 +4,23 @@ ### Compatibility and validation +- Fix CLI `--args` conversion for nested bigint arrays and named/positional + tuples, including signed values. Previously `[["9007199254740993n"]]` failed + before RPC while the scalar form encoded correctly. Reject unsafe numeric + integers before signing or sending instead of accepting a rounded value. + Verify real CLI compile/write/read, original scalar/array overloads and + full-word state with fresh ML-DSA-65/Dilithium3 keystores, P2P import, + history and finalized crash recovery against node source + `e94038717a1522080ecf701edacbde21cf37b870`. Genesis predeployment is explicit; + native CREATE and matching public package/profile acceptance remain separate. + - Extend the live overload entry to root-signed AA batches for both ML-DSA-65 and Dilithium3 across canonical, inherited, internal, free-function and array forms. Verify tampered-data/signature/algorithm rejection, later-call atomic rollback, mined state and outer fees/nonces, live P2P import, historical reads, offline catch-up and finalized crash recovery followed by a new batch. Node source remains `e94038717a1522080ecf701edacbde21cf37b870`; genesis - predeployment is explicit. Native CREATE, CLI contract-write entries and - matching public package/profile acceptance remain separate. + predeployment is explicit. Native CREATE and matching public package/profile acceptance remain separate. - Verify inherited, internal, free-function and array overloads through their original selectors on a real isolated memory node, including full-word @@ -21,8 +30,7 @@ rollback, fee/nonce and historical getters, independent P2P follower import, offline catch-up and finalized RocksDB crash recovery with continued writes. Checked against shell-chain source `e94038717a1522080ecf701edacbde21cf37b870`. - These isolated dev cases use genesis predeployment; native CREATE, CLI - contract-write entries and matching public package/profile acceptance remain open. + These isolated dev cases use genesis predeployment; native CREATE and matching public package/profile acceptance remain open. - Extend the canonical overload RPC regression through real ML-DSA mined writes, rollback, fee/nonce and history checks, P2P follower import, and RocksDB crash diff --git a/README.md b/README.md index cc62044..e8c37cf 100644 --- a/README.md +++ b/README.md @@ -638,6 +638,14 @@ npx shell-sdk contract read \ --function getNumber ``` +CLI `--args` converts decimal strings ending in `n` to bigint at every level, +including arrays and named or positional tuples. For example, +`--args '[ ["9007199254740993n", "7n"] ]'` preserves both `uint256[]` elements; +signed integer arguments can use `"-7n"`. JSON numeric integers outside the safe +JavaScript range are rejected; use the string form to retain the exact value. +For overloads, pass the original signature, such as `--function 'set(uint256[])'`, +with the complete artifact ABI. + --- ### System contracts @@ -1187,7 +1195,9 @@ SHELL_NODE_BIN=/path/to/shell-node node --test --test-name-pattern='canonical ov It checks the original scalar overloads and inherited, internal, free-function and array compositions through genuinely signed ML-DSA-65 and Dilithium3 -ordinary writes and root-signed AA batches. AA cases reject tampered inner +ordinary writes, root-signed AA batches and actual CLI compile/write/read +commands using fresh encrypted keystores. CLI cases verify nested bigint arguments +and 32-byte address values. AA cases reject tampered inner calldata, corrupt signatures and a mismatched algorithm identifier, and verify that a later failing call rolls back earlier writes in the same batch while the outer fee and nonce still apply. The original ABI is retained, and fixed diff --git a/src/cli.ts b/src/cli.ts index 1cfc779..c006cc6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -88,16 +88,19 @@ function parseJsonArgs(raw: string | undefined): unknown[] { if (!raw) { return []; } - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) { - throw new Error("--args must be a JSON array"); - } - return parsed.map((value) => { - if (typeof value === "string" && /^\d+n$/.test(value)) { + const parsed: unknown = JSON.parse(raw, (_key, value: unknown) => { + if (typeof value === "string" && /^-?\d+n$/.test(value)) { return BigInt(value.slice(0, -1)); } + if (typeof value === "number" && Number.isInteger(value) && !Number.isSafeInteger(value)) { + throw new Error('--args contains an unsafe integer; use an n-suffixed string'); + } return value; }); + if (!Array.isArray(parsed)) { + throw new Error("--args must be a JSON array"); + } + return parsed; } async function loadSigner(options: CliOptions) { diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index 95bab61..819d114 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -63,3 +63,50 @@ test('cli native context compile enforces explicit activation arguments', async assert.equal(JSON.parse(await readFile(out,'utf8')).contractName, 'Context'); } finally { await rm(tempDir, {recursive:true,force:true}); } }); + + +test('cli preserves nested bigint array and tuple words and refuses unsafe JSON integers', async () => { + const {createServer}=await import('node:http'); + const {spawn}=await import('node:child_process'); + const {once}=await import('node:events'); + const {toFunctionSelector}=await import('viem'); + const requests=[]; + const server=createServer(async(request,response)=>{ + let raw='';for await(const chunk of request)raw+=chunk; + const body=JSON.parse(raw);requests.push(body); + response.setHeader('Content-Type','application/json'); + response.end(JSON.stringify({jsonrpc:'2.0',id:body.id,result:'0x'+'00'.repeat(32)})); + }); + const directory=await mkdtemp(path.join(os.tmpdir(),'shell-sdk-cli-args-')); + const artifact=path.join(directory,'Arguments.json'); + const abi=[ + {type:'function',name:'array',stateMutability:'pure',inputs:[{name:'values',type:'uint256[]'}],outputs:[{type:'uint256'}]}, + {type:'function',name:'tuple',stateMutability:'pure',inputs:[{name:'value',type:'tuple',components:[{name:'signed',type:'int256'},{name:'values',type:'uint256[]'}]}],outputs:[{type:'uint256'}]}, + ]; + const word=value=>BigInt.asUintN(256,BigInt(value)).toString(16).padStart(64,'0'); + try { + await writeFile(artifact,JSON.stringify({contractName:'Arguments',abi,bytecode:'0x00'})); + server.listen(0,'127.0.0.1');await once(server,'listening'); + async function run(fun,args) { + const child=spawn(process.execPath,[CLI,'contract','read','--artifact',artifact,'--address','0x'+'99'.repeat(32), + '--function',fun,'--args',args,'--rpc',`http://127.0.0.1:${server.address().port}`],{stdio:['ignore','pipe','pipe']}); + let stdout='',stderr='';child.stdout.on('data',data=>stdout+=data);child.stderr.on('data',data=>stderr+=data); + const [status]=await once(child,'exit');return {status,stdout,stderr}; + } + const value=(1n<<200n)+73n; + const array=await run('array(uint256[])',JSON.stringify([[value.toString()+'n','7n']])); + assert.equal(array.status,0,array.stderr);assert.equal(array.stdout.trim(),'"0"'); + assert.equal(requests.at(-1).params[0].data,toFunctionSelector('array(uint256[])')+word(32)+word(2)+word(value)+word(7)); + for(const tupleArgs of [[['-7n',[value.toString()+'n','7n']]], [{signed:'-7n',values:[value.toString()+'n','7n']}]] ) { + const tuple=await run('tuple((int256,uint256[]))',JSON.stringify(tupleArgs)); + assert.equal(tuple.status,0,tuple.stderr); + assert.equal(requests.at(-1).params[0].data,toFunctionSelector('tuple((int256,uint256[]))')+word(32)+word(-7)+word(64)+word(2)+word(value)+word(7)); + } + const count=requests.length; + const unsafe=await run('array(uint256[])','[[9007199254740993]]'); + assert.equal(unsafe.status,1);assert.match(unsafe.stderr,/unsafe integer.*n-suffixed/);assert.equal(requests.length,count); + } finally { + await new Promise(resolve=>server.close(resolve)); + await rm(directory,{recursive:true,force:true}); + } +}); diff --git a/tests/contracts.test.mjs b/tests/contracts.test.mjs index 2df7ddb..3b3d7b2 100644 --- a/tests/contracts.test.mjs +++ b/tests/contracts.test.mjs @@ -670,7 +670,7 @@ test('read and write helpers share explicit original overload selection', async const derivedLiveCases=JSON.parse(readFileSync(new URL('./fixtures/native-derived-overloads.json',import.meta.url),'utf8')); for(const variant of [{kind:'canonical',array:false},...derivedLiveCases]) { for(const algorithm of ['MlDsa65','Dilithium3']) { - for(const entry of ['ordinary','AA']) { + for(const entry of ['ordinary','AA','CLI']) { test(`canonical overload real RPC: ${variant.kind}/${algorithm}/${entry} mined state, P2P import and crash recovery`, {skip:!process.env.SHELL_NODE_BIN, timeout:120000}, async () => { const {mkdtemp,mkdir,writeFile,readFile,rm}=await import('node:fs/promises'); @@ -685,13 +685,19 @@ for(const variant of [{kind:'canonical',array:false},...derivedLiveCases]) { const {ShellSigner}=await import('../dist/signer.js'); const binary=process.env.SHELL_NODE_BIN; const directory=await mkdtemp(join(tmpdir(),`shell-sdk-overload-${variant.kind}-${algorithm}-${entry}-`)); - const signer=new ShellSigner(algorithm,generateAdapter(algorithm)); + let signer=new ShellSigner(algorithm,generateAdapter(algorithm)); const contract='0x'+'99'.repeat(32), zero='0x'+'00'.repeat(32); const second='0x'+'56'.repeat(12)+'12'.repeat(20); const initialBalance=10000000000000000000n, maxFee=1000000000n; const password=randomBytes(24).toString('hex'); const nodes={},receipts=[],history=[]; let passed=false, balance=initialBalance; + const artifactPath=join(directory,'Stateful.json'),signerPath=join(directory,'signer.json'); + function cli(command,options=[]) { + return spawnSync(process.execPath,[new URL('../dist/cli.js',import.meta.url).pathname,'contract',command,...options], + {encoding:'utf8',timeout:30000,env:{...process.env,SHELL_KEYSTORE_PASSWORD:password}}); + } + const jsonArgs=args=>JSON.stringify(args,(_key,value)=>typeof value==='bigint'?value.toString()+'n':value); async function until(check, label, timeout=15000) { const deadline=Date.now()+timeout; while(Date.now(){ @@ -753,7 +759,13 @@ for(const variant of [{kind:'canonical',array:false},...derivedLiveCases]) { async function wordsAt(name,tag) { const words=[]; for(const functionName of [variant.kind==='canonical'?'owner()':'nativeOwner()',variant.kind==='canonical'?'amount()':'nativeAmount()','writes()']) { - const value=await readContract({provider:nodes[name].provider,address:contract, + let value; + if(entry==='CLI'&&tag==='latest') { + const result=cli('read',['--artifact',artifactPath,'--address',contract,'--function',functionName, + '--rpc',nodes[name].url]); + assert.equal(result.status,0,result.stderr);value=JSON.parse(result.stdout); + if(functionName!=='nativeOwner()'&&functionName!=='owner()')value=BigInt(value); + } else value=await readContract({provider:nodes[name].provider,address:contract, abi:artifact.abi,functionName,blockTag:tag}); words.push(typeof value==='bigint'?word(value):value); } @@ -791,6 +803,17 @@ contract StatefulOverloads { const originalArtifact=await compileSolidity({...options,target:'evm'}); artifact=await compileSolidity({...options,target:'pqvm'}); assert.deepEqual(artifact.abi,originalArtifact.abi); + if(entry==='CLI') { + const source=join(directory,'Stateful.sol');await writeFile(source,content); + const compiled=cli('compile',['--source',source,'--contract',options.contractName,'--out',artifactPath,'--pqvm']); + assert.equal(compiled.status,0,compiled.stderr); + artifact=JSON.parse(await readFile(artifactPath,'utf8'));assert.deepEqual(artifact.abi,originalArtifact.abi); + const account=spawnSync(binary,['--password-stdin','key','generate','--algorithm',algorithm==='MlDsa65'?'mldsa65':'dilithium3', + '--output',signerPath],{input:password+'\n',encoding:'utf8',timeout:30000}); + assert.equal(account.status,0,account.stderr||account.stdout); + const {decryptKeystore}=await import('../dist/keystore.js');signer.dispose(); + signer=await decryptKeystore(JSON.parse(await readFile(signerPath,'utf8')),password); + } const key=spawnSync(binary,['--password-stdin','key','generate','--algorithm','mldsa65', '--output',join(directory,'validator.json')],{input:password+'\n',encoding:'utf8',timeout:30000}); assert.equal(key.status,0,key.stderr||key.stdout); @@ -837,7 +860,7 @@ contract StatefulOverloads { ...(variant.array?[[[['set(address)',OVERLOAD_WORD],['empty',null]],false,[second,word(OVERLOAD_AMOUNT+1n),word(3)]]]:[]), [[['set(uint256)',0n],['set(address)',OVERLOAD_WORD]],true,[OVERLOAD_WORD,word(1),word(5)]], ]; - const steps=entry==='AA'?aaSteps:ordinarySteps; + const steps=entry==='AA'?aaSteps:entry==='CLI'?ordinarySteps.filter(step=>step[0]!=='truncated'):ordinarySteps; async function execute(step,nonce,checkFollower=true) { const [functionName,argument]=step; const success=step[entry==='AA'?1:2],words=step[entry==='AA'?2:3]; @@ -874,7 +897,22 @@ contract StatefulOverloads { const signature=variant.array?functionName.replace('address)','address[])').replace('uint256)','uint256[])'):functionName; const args=[variant.array?[argument,typeof argument==='bigint'?23n:second]:argument]; const addressSignature=variant.array?'set(address[])':'set(address)'; - if(functionName==='truncated'||functionName==='empty') { + if(entry==='CLI') { + const cliSignature=functionName==='empty'?addressSignature:signature; + const cliArgs=functionName==='empty'?[[]]:args; + const encoded=encodeFunctionData({abi,functionName:cliSignature,args:cliArgs}); + const result=cli('write',['--artifact',artifactPath,'--keystore',signerPath,'--address',contract,'--function',cliSignature, + '--args',jsonArgs(cliArgs),'--rpc',nodes.validator.url,'--chain-id','1337','--gas-limit','500000']); + assert.equal(result.status,success?0:1,result.stderr); + if(success) { + const output=JSON.parse(result.stdout);assert.equal(output.nonce,nonce);hash=output.hash; + } else { + assert.match(result.stderr,/contract write reverted:/);hash=result.stderr.match(/0x[0-9a-f]{64}/)?.[0];assert.ok(hash); + } + const submitted=await rpc('validator','eth_getTransactionByHash',[hash]); + assert.equal(submitted.input,encoded); + assert.deepEqual(await wordsAt('validator','latest'),words); + } else if(functionName==='truncated'||functionName==='empty') { const tx=buildContractCallTransaction({address:contract,abi,functionName:addressSignature,args:functionName==='empty'?[[]]:args, chainId:1337,nonce,gasLimit:500000,maxFeePerGas:Number(maxFee),maxPriorityFeePerGas:0}); if(functionName==='truncated')tx.data=tx.data.slice(0,-2); @@ -895,7 +933,10 @@ contract StatefulOverloads { assert.equal(receipt.type,entry==='AA'?'0x7e':'0x2'); const block=await rpc('validator','eth_getBlockByNumber',[receipt.blockNumber,false]); assert.equal(block.hash,receipt.blockHash);assert.ok(block.transactions.includes(hash)); - const baseFee=BigInt(block.baseFeePerGas),price=baseFee