# Hold a wallet batch until its preflight policy has run

A JavaScript agent can build an EIP-5792 `wallet_sendCalls` request with several top-level calls. If you inspect one call and then hand the batch to your wallet provider, the important question is whether every call passed your policy before the provider receives the request.

The public `@m2msentinel/sdk@1.2.7` package includes `guardWalletSendCalls` for a restricted Base Account `wallet_sendCalls` shape. It preflights the first call, evaluates your policy for that observation, and only then schedules remaining preflights in bounded waves. It forwards a detached copy of the request to your EIP-1193 provider only after all top-level observations and caller policies pass. The policy belongs to your application. See the [versioned npm package](https://www.npmjs.com/package/@m2msentinel/sdk/v/1.2.7) and the SDK repository's [public no-network example](https://github.com/M2M-Sentinel/m2m-sentinel-sdk/blob/main/examples/base_account_paymaster_guard.js).

## Run a local reproduction

Use Node.js 18 or newer. The install command pins the package to version 1.2.7. This article provides no package-lock.json; commit a lockfile in your own project if you need npm to retain the resolved package integrity.

In a new folder, run:

```sh
mkdir wallet-sendcalls-demo
cd wallet-sendcalls-demo
npm init -y
npm install --no-save --package-lock=false --ignore-scripts --no-audit --no-fund @m2msentinel/sdk@1.2.7
mkdir test
```

Save the complete runner below as `example.cjs`. It uses two fabricated observations, a fake client, a fake provider, and a caller policy. It makes no M2M Sentinel API or Base RPC call.

```js
const { guardWalletSendCalls } = require('@m2msentinel/sdk');

const hash = `sha256:${'a'.repeat(64)}`;
const blockHash = `0x${'b'.repeat(64)}`;
const target = '0x1111111111111111111111111111111111111111';
const calls = [
  { to: target, data: '0x12345678', value: '0x0' },
  { to: target, data: '0x87654321', value: '0x0' }
];
const request = {
  method: 'wallet_sendCalls',
  params: [{ version: '1.0', chainId: '0x2105', from: '0x2222222222222222222222222222222222222222', calls }]
};

function fixture(transaction) {
  const blockNumber = '0x100';
  const selector = transaction.data.length >= 10 ? transaction.data.slice(0, 10).toLowerCase() : null;
  return {
    evidenceGrade: true,
    evidenceStatus: 'VERIFIED',
    status: 'SUCCESS',
    operation: 'TRANSACTION_PREFLIGHT_OBSERVATION',
    notASafetyGuarantee: true,
    reachability: 'NOT_ESTABLISHED',
    limitations: ['Fixture data only; this is not a safety decision.'],
    conclusion: null,
    observationBlock: {
      status: 'PINNED', chainId: 8453, network: 'eip155:8453', blockNumber,
      blockTag: blockNumber, blockHash, trustLevel: 'HIGH_TRUST_PRIMARY'
    },
    effectiveTrust: 'HIGH_TRUST_PRIMARY',
    bytecodeHashes: { finalTargetBytecodeHash: hash },
    request: transaction,
    selectorHex: selector,
    surfaceTarget: transaction.to,
    resolvedExecutionTarget: target,
    resolution: { status: 'COMPLETE', resolutionComplete: true, proxyType: 'NONE' },
    simulationObservation: {
      attempted: true, trusted: true, evidenceOnly: true,
      outcome: 'SUCCEEDED', blockTag: blockNumber
    },
    rpcObservation: {
      failedReadCount: 0, failures: [], stateBearingCallCount: 0,
      calls: [], pinnedBlockTag: blockNumber
    },
    capabilityEvidence: {
      transactionSelector: selector,
      sourceAddress: target,
      executionRole: 'RESOLVED_EXECUTING_CODE',
      notASafetyGuarantee: true,
      reachability: 'NOT_ESTABLISHED',
      dissection: {
        isValidContract: true, bytecodeSizeBytes: 1, targetBytecodeHash: hash,
        capabilities: [], detectedCapabilities: []
      }
    }
  };
}

async function main() {
  const preflights = [];
  const providerRequests = [];
  const policyCalls = [];
  const client = {
    async preflightTransaction(transaction) {
      preflights.push(transaction);
      return fixture(transaction);
    }
  };
  const provider = {
    async request(value) {
      providerRequests.push(value);
      return { accepted: true, callCount: value.params[0].calls.length };
    }
  };
  const result = await guardWalletSendCalls({
    client,
    provider,
    request,
    policy: (_observation, context) => { policyCalls.push(context.callIndex); return { allow: true }; }
  });
  console.log(`Preflight observations: ${preflights.length}`);
  console.log(`Caller policies: ${policyCalls.length} accepted`);
  console.log(`Provider requests: ${providerRequests.length}`);
  console.log(`Provider result: ${JSON.stringify(result)}`);
}

main().catch((error) => {
  console.error(error.message);
  process.exitCode = 1;
});
```

Run it:

```sh
node example.cjs
```

Expected output:

```text
Preflight observations: 2
Caller policies: 2 accepted
Provider requests: 1
Provider result: {"accepted":true,"callCount":2}
```

## Check the stop cases

Save the following complete test file as `test/guard.test.cjs`, then run it with Node's built-in test runner. It uses the same published SDK and local synthetic fixtures; it does not import files from a private repository or from the tutorial packet.

```js
const test = require('node:test');
const assert = require('node:assert/strict');
const { guardWalletSendCalls } = require('@m2msentinel/sdk');

const HASH = `sha256:${'a'.repeat(64)}`;
const BLOCK_HASH = `0x${'b'.repeat(64)}`;
const TARGET = '0x1111111111111111111111111111111111111111';
const request = {
  method: 'wallet_sendCalls',
  params: [{
    version: '1.0', chainId: '0x2105', from: '0x2222222222222222222222222222222222222222',
    calls: [
      { to: TARGET, data: '0x12345678', value: '0x0' },
      { to: TARGET, data: '0x87654321', value: '0x0' }
    ]
  }]
};

function observation(transaction, blockNumber = '0x100', blockHash = BLOCK_HASH) {
  const selector = transaction.data.slice(0, 10).toLowerCase();
  return {
    evidenceGrade: true, evidenceStatus: 'VERIFIED', status: 'SUCCESS',
    operation: 'TRANSACTION_PREFLIGHT_OBSERVATION', notASafetyGuarantee: true,
    reachability: 'NOT_ESTABLISHED', limitations: ['Fixture data only.'], conclusion: null,
    observationBlock: {
      status: 'PINNED', chainId: 8453, network: 'eip155:8453',
      blockNumber, blockTag: blockNumber, blockHash, trustLevel: 'HIGH_TRUST_PRIMARY'
    },
    effectiveTrust: 'HIGH_TRUST_PRIMARY', bytecodeHashes: { finalTargetBytecodeHash: HASH },
    request: transaction, selectorHex: selector, surfaceTarget: transaction.to,
    resolvedExecutionTarget: TARGET,
    resolution: { status: 'COMPLETE', resolutionComplete: true, proxyType: 'NONE' },
    simulationObservation: {
      attempted: true, trusted: true, evidenceOnly: true, outcome: 'SUCCEEDED', blockTag: blockNumber
    },
    rpcObservation: {
      failedReadCount: 0, failures: [], stateBearingCallCount: 0,
      calls: [], pinnedBlockTag: blockNumber
    },
    capabilityEvidence: {
      transactionSelector: selector, sourceAddress: TARGET,
      executionRole: 'RESOLVED_EXECUTING_CODE', notASafetyGuarantee: true,
      reachability: 'NOT_ESTABLISHED',
      dissection: {
        isValidContract: true, bytecodeSizeBytes: 1, targetBytecodeHash: HASH,
        capabilities: [], detectedCapabilities: []
      }
    }
  };
}

function fakes(responseFor = (transaction) => observation(transaction)) {
  const preflights = [];
  const providerRequests = [];
  return {
    preflights, providerRequests,
    client: {
      async preflightTransaction(transaction) {
        preflights.push(transaction);
        return responseFor(transaction, preflights.length - 1);
      }
    },
    provider: {
      async request(value) {
        providerRequests.push(value);
        return { accepted: true };
      }
    }
  };
}

test('rejecting the anchor policy makes one preflight and no provider request', async () => {
  const fake = fakes();
  await assert.rejects(guardWalletSendCalls({
    client: fake.client, provider: fake.provider, request,
    policy: (_result, context) => context.callIndex === 0 ? { allow: false, reason: 'fixture rejection' } : true
  }), /fixture rejection/);
  assert.equal(fake.preflights.length, 1);
  assert.equal(fake.providerRequests.length, 0);
});

test('a mismatched later observation makes no provider request', async () => {
  const fake = fakes((transaction, index) => index === 0
    ? observation(transaction)
    : observation(transaction, '0x101', `0x${'c'.repeat(64)}`));
  await assert.rejects(guardWalletSendCalls({
    client: fake.client, provider: fake.provider, request, policy: () => true
  }), /different Base block identity/);
  assert.equal(fake.preflights.length, 2);
  assert.equal(fake.providerRequests.length, 0);
});

test('two accepted observations lead to one provider request after both policies pass', async () => {
  const fake = fakes();
  const policyCalls = [];
  const result = await guardWalletSendCalls({
    client: fake.client, provider: fake.provider, request,
    policy: (_result, context) => {
      policyCalls.push(context.callIndex);
      assert.equal(fake.providerRequests.length, 0);
      return { allow: true };
    }
  });
  assert.deepEqual(policyCalls, [0, 1]);
  assert.equal(fake.preflights.length, 2);
  assert.equal(fake.providerRequests.length, 1);
  assert.deepEqual(fake.providerRequests[0], request);
  assert.deepEqual(result, { accepted: true });
});
```

Run:

```sh
node --test test/guard.test.cjs
```

The three checks prove only the local client boundary:

- Rejecting the anchor call's caller policy stops after one preflight and makes zero provider requests.
- A later observation pinned to a different Base block identity makes zero provider requests.
- Two accepted observations produce one provider method invocation, after both policy calls pass.

## Limits

The block number, hashes, and successful simulation in these examples are fabricated values. They exercise published client-side validation and sequencing, not a live Base observation. The fake provider does not sign or broadcast.

The guard checks each top-level call in its documented request shape. It does not inspect inner UserOperation semantics, decide whether a transaction is safe, or replace your policy or wallet's own validation. A local count of one provider method invocation is not an end-to-end exactly-once guarantee; a real provider or bundler can have its own retry and submission behavior.

**Question for wallet integration owners:** Should a policy rejection on the first call stop later preflights immediately, or do you need later-call evidence for diagnostics while still withholding the batch from the provider?

If you own this boundary and have one concrete integration question, contact [contact@m2msentinel.com](mailto:contact@m2msentinel.com).
