Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
15 changes: 9 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
47 changes: 47 additions & 0 deletions tests/cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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});
}
});
55 changes: 48 additions & 7 deletions tests/contracts.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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()<deadline) {
Expand Down Expand Up @@ -727,7 +733,7 @@ for(const variant of [{kind:'canonical',array:false},...derivedLiveCases]) {
if(bootnode)args.push('--bootnode',bootnode);
const child=spawn(binary,args,{stdio:['pipe',log,log]});
child.stdin.end(name==='validator'?password+'\n':'');
const node={name,child,exit:once(child,'exit'),log,logPath,
const node={name,child,exit:once(child,'exit'),log,logPath,url:`http://127.0.0.1:${rpcPort}`,
provider:createShellProvider({rpcHttpUrl:`http://127.0.0.1:${rpcPort}`})};
nodes[name]=node;
await until(async()=>{
Expand All @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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);
Expand All @@ -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<maxFee?baseFee:maxFee;
const submitted=entry==='CLI'?await rpc('validator','eth_getTransactionByHash',[hash]):null;
const feeCap=submitted?BigInt(submitted.maxFeePerGas):maxFee;
const priority=submitted?BigInt(submitted.maxPriorityFeePerGas):0n;
const baseFee=BigInt(block.baseFeePerGas),price=baseFee+priority<feeCap?baseFee+priority:feeCap;
assert.equal(BigInt(receipt.effectiveGasPrice),price);
balance-=BigInt(receipt.gasUsed)*price;
const expected={words,nonce:BigInt(nonce+1),balance};
Expand Down
Loading