@openzeppelin/upgrades-core 1.16.1 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/contracts/Initializable.sol +4 -1
  2. package/dist/deployment.js +2 -3
  3. package/dist/deployment.js.map +1 -1
  4. package/dist/eip-1967.d.ts.map +1 -1
  5. package/dist/eip-1967.js +2 -7
  6. package/dist/eip-1967.js.map +1 -1
  7. package/dist/impl-store.js +6 -8
  8. package/dist/impl-store.js.map +1 -1
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +3 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/levenshtein.d.ts +4 -1
  14. package/dist/levenshtein.d.ts.map +1 -1
  15. package/dist/levenshtein.js +1 -1
  16. package/dist/levenshtein.js.map +1 -1
  17. package/dist/manifest-storage-layout.js +1 -1
  18. package/dist/manifest-storage-layout.js.map +1 -1
  19. package/dist/manifest.d.ts +5 -0
  20. package/dist/manifest.d.ts.map +1 -1
  21. package/dist/manifest.js +54 -10
  22. package/dist/manifest.js.map +1 -1
  23. package/dist/manifest.test.js +82 -0
  24. package/dist/manifest.test.js.map +1 -1
  25. package/dist/provider.d.ts.map +1 -1
  26. package/dist/provider.js +6 -6
  27. package/dist/provider.js.map +1 -1
  28. package/dist/provider.test.js +5 -5
  29. package/dist/provider.test.js.map +1 -1
  30. package/dist/proxy-kind.js +1 -2
  31. package/dist/proxy-kind.js.map +1 -1
  32. package/dist/scripts/migrate-oz-cli-project.js +1 -1
  33. package/dist/scripts/migrate-oz-cli-project.js.map +1 -1
  34. package/dist/scripts/migrate-oz-cli-project.test.js +1 -1
  35. package/dist/scripts/migrate-oz-cli-project.test.js.map +1 -1
  36. package/dist/src-decoder.js +1 -2
  37. package/dist/src-decoder.js.map +1 -1
  38. package/dist/storage/compare.d.ts +21 -3
  39. package/dist/storage/compare.d.ts.map +1 -1
  40. package/dist/storage/compare.js +83 -22
  41. package/dist/storage/compare.js.map +1 -1
  42. package/dist/storage/extract.js +6 -9
  43. package/dist/storage/extract.js.map +1 -1
  44. package/dist/storage/gap.d.ts +18 -0
  45. package/dist/storage/gap.d.ts.map +1 -0
  46. package/dist/storage/gap.js +29 -0
  47. package/dist/storage/gap.js.map +1 -0
  48. package/dist/storage/layout.js +3 -4
  49. package/dist/storage/layout.js.map +1 -1
  50. package/dist/storage/report-gap.test.d.ts +2 -0
  51. package/dist/storage/report-gap.test.d.ts.map +1 -0
  52. package/dist/storage/report-gap.test.js +106 -0
  53. package/dist/storage/report-gap.test.js.map +1 -0
  54. package/dist/storage/report-rename-retype.test.js +24 -0
  55. package/dist/storage/report-rename-retype.test.js.map +1 -1
  56. package/dist/storage/report.d.ts +1 -1
  57. package/dist/storage/report.d.ts.map +1 -1
  58. package/dist/storage/report.js +59 -10
  59. package/dist/storage/report.js.map +1 -1
  60. package/dist/storage.test.js +422 -3
  61. package/dist/storage.test.js.map +1 -1
  62. package/dist/stub-provider.d.ts +1 -1
  63. package/dist/stub-provider.d.ts.map +1 -1
  64. package/dist/stub-provider.js +5 -7
  65. package/dist/stub-provider.js.map +1 -1
  66. package/dist/usage-error.d.ts +9 -0
  67. package/dist/usage-error.d.ts.map +1 -1
  68. package/dist/usage-error.js +16 -1
  69. package/dist/usage-error.js.map +1 -1
  70. package/dist/utils/parse-type-id.js +2 -3
  71. package/dist/utils/parse-type-id.js.map +1 -1
  72. package/dist/validate/data.js +26 -7
  73. package/dist/validate/data.js.map +1 -1
  74. package/dist/validate/index.d.ts +1 -1
  75. package/dist/validate/index.d.ts.map +1 -1
  76. package/dist/validate/index.js.map +1 -1
  77. package/dist/validate/overrides.d.ts +34 -3
  78. package/dist/validate/overrides.d.ts.map +1 -1
  79. package/dist/validate/overrides.js +7 -7
  80. package/dist/validate/overrides.js.map +1 -1
  81. package/dist/validate/query.js +3 -5
  82. package/dist/validate/query.js.map +1 -1
  83. package/dist/validate/report.js +1 -2
  84. package/dist/validate/report.js.map +1 -1
  85. package/dist/validate/run.js +6 -10
  86. package/dist/validate/run.js.map +1 -1
  87. package/dist/version.js +1 -1
  88. package/dist/version.js.map +1 -1
  89. package/package.json +3 -5
  90. package/src/eip-1967.ts +2 -4
  91. package/src/index.ts +2 -0
  92. package/src/levenshtein.ts +4 -2
  93. package/src/manifest.test.ts +103 -1
  94. package/src/manifest.ts +62 -6
  95. package/src/provider.ts +5 -3
  96. package/src/storage/compare.ts +88 -18
  97. package/src/storage/gap.ts +26 -0
  98. package/src/storage/report-gap.test.ts +119 -0
  99. package/src/storage/report-gap.test.ts.md +59 -0
  100. package/src/storage/report-gap.test.ts.snap +0 -0
  101. package/src/storage/report-rename-retype.test.ts +27 -0
  102. package/src/storage/report-rename-retype.test.ts.md +7 -0
  103. package/src/storage/report-rename-retype.test.ts.snap +0 -0
  104. package/src/storage/report.test.ts.md +1 -4
  105. package/src/storage/report.test.ts.snap +0 -0
  106. package/src/storage/report.ts +83 -12
  107. package/src/storage.test.ts +466 -2
  108. package/src/storage.test.ts.snap +0 -0
  109. package/src/usage-error.ts +22 -0
  110. package/src/validate/data.ts +1 -1
  111. package/src/validate/index.ts +1 -1
  112. package/src/validate/overrides.ts +39 -3
  113. package/artifacts/@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol/ProxyAdmin.json +0 -160
@@ -1,5 +1,107 @@
1
- import test from 'ava';
1
+ import test, { ExecutionContext } from 'ava';
2
2
  import { Manifest, ManifestData, normalizeManifestData } from './manifest';
3
+ import { promises as fs } from 'fs';
4
+ import path from 'path';
5
+
6
+ async function writeTestManifest(file: string) {
7
+ const testManifest = {
8
+ manifestVersion: '3.2',
9
+ impls: {},
10
+ proxies: [
11
+ {
12
+ address: '0x123',
13
+ txHash: '0x0',
14
+ kind: 'uups',
15
+ },
16
+ ],
17
+ };
18
+ await fs.mkdir(path.dirname(file), { recursive: true });
19
+ await fs.writeFile(file, JSON.stringify(testManifest, null, 2) + '\n');
20
+ }
21
+
22
+ async function deleteFile(t: ExecutionContext, file: string) {
23
+ try {
24
+ await fs.unlink(file);
25
+ } catch (e: any) {
26
+ if (!e.message.includes('ENOENT')) {
27
+ t.fail(e);
28
+ }
29
+ }
30
+ }
31
+
32
+ async function assertOldName(t: ExecutionContext<unknown>, id: number) {
33
+ await fs.access(`.openzeppelin/unknown-${id}.json`);
34
+ t.throwsAsync(fs.access(`.openzeppelin/polygon-mumbai.json`));
35
+ }
36
+
37
+ async function assertNewName(t: ExecutionContext<unknown>, id: number) {
38
+ t.throwsAsync(fs.access(`.openzeppelin/unknown-${id}.json`));
39
+ await fs.access(`.openzeppelin/polygon-mumbai.json`);
40
+ }
41
+
42
+ async function deleteManifests(t: ExecutionContext<unknown>, id: number) {
43
+ await deleteFile(t, `.openzeppelin/unknown-${id}.json`);
44
+ await deleteFile(t, '.openzeppelin/polygon-mumbai.json');
45
+ }
46
+
47
+ test.serial('multiple manifests', async t => {
48
+ const id = 80001;
49
+ await deleteManifests(t, id);
50
+
51
+ await writeTestManifest(`.openzeppelin/unknown-${id}.json`);
52
+ await writeTestManifest(`.openzeppelin/polygon-mumbai.json`);
53
+
54
+ const manifest = new Manifest(id);
55
+ await manifest.lockedRun(async () => {
56
+ await t.throwsAsync(() => manifest.read(), {
57
+ message: new RegExp(
58
+ `Network files with different names .openzeppelin/unknown-${id}.json and .openzeppelin/polygon-mumbai.json were found for the same network.`,
59
+ ),
60
+ });
61
+ });
62
+
63
+ await deleteManifests(t, id);
64
+ });
65
+
66
+ test.serial('rename manifest', async t => {
67
+ const id = 80001;
68
+ await deleteManifests(t, id);
69
+
70
+ await writeTestManifest(`.openzeppelin/unknown-${id}.json`);
71
+
72
+ const manifest = new Manifest(id);
73
+ t.is(manifest.file, `.openzeppelin/polygon-mumbai.json`);
74
+
75
+ t.throwsAsync(fs.access(`.openzeppelin/chain-${id}.lock`));
76
+
77
+ await assertOldName(t, id);
78
+ await manifest.lockedRun(async () => {
79
+ await fs.access(`.openzeppelin/chain-${id}.lock`);
80
+ const data = await manifest.read();
81
+ data.proxies.push({
82
+ address: '0x456',
83
+ txHash: '0x0',
84
+ kind: 'uups',
85
+ });
86
+
87
+ await assertOldName(t, id);
88
+ await manifest.write(data);
89
+ await assertNewName(t, id);
90
+ });
91
+ await assertNewName(t, id);
92
+
93
+ t.throwsAsync(fs.access(`.openzeppelin/chain-${id}.lock`));
94
+
95
+ // check that the contents were persisted
96
+ const data = await new Manifest(id).read();
97
+ t.true(data.proxies[0].address === '0x123');
98
+ t.true(data.proxies[1].address === '0x456');
99
+ await assertNewName(t, id);
100
+
101
+ t.throwsAsync(fs.access(`.openzeppelin/chain-${id}.lock`));
102
+
103
+ await deleteManifests(t, id);
104
+ });
3
105
 
4
106
  test('manifest name for a known network', t => {
5
107
  const manifest = new Manifest(1);
package/src/manifest.ts CHANGED
@@ -8,6 +8,7 @@ import type { Deployment } from './deployment';
8
8
  import type { StorageLayout } from './storage';
9
9
  import { pick } from './utils/pick';
10
10
  import { mapValues } from './utils/map-values';
11
+ import { UpgradesError } from './error';
11
12
 
12
13
  const currentManifestVersion = '3.2';
13
14
 
@@ -40,15 +41,23 @@ function defaultManifest(): ManifestData {
40
41
  const manifestDir = '.openzeppelin';
41
42
 
42
43
  export class Manifest {
44
+ readonly chainId: number;
43
45
  readonly file: string;
46
+ private readonly fallbackFile: string;
47
+
44
48
  private locked = false;
45
49
 
46
50
  static async forNetwork(provider: EthereumProvider): Promise<Manifest> {
47
51
  return new Manifest(await getChainId(provider));
48
52
  }
49
53
 
50
- constructor(readonly chainId: number) {
51
- const name = networkNames[chainId] ?? `unknown-${chainId}`;
54
+ constructor(chainId: number) {
55
+ this.chainId = chainId;
56
+
57
+ const fallbackName = `unknown-${this.chainId}`;
58
+ this.fallbackFile = path.join(manifestDir, `${fallbackName}.json`);
59
+
60
+ const name = networkNames[this.chainId] ?? fallbackName;
52
61
  this.file = path.join(manifestDir, `${name}.json`);
53
62
  }
54
63
 
@@ -89,10 +98,55 @@ export class Manifest {
89
98
  });
90
99
  }
91
100
 
101
+ private async exists(file: string): Promise<boolean> {
102
+ try {
103
+ await fs.access(file);
104
+ return true;
105
+ } catch (e: any) {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ private async readFile(): Promise<string> {
111
+ if (this.file === this.fallbackFile) {
112
+ return await fs.readFile(this.file, 'utf8');
113
+ } else {
114
+ const fallbackExists = await this.exists(this.fallbackFile);
115
+ const fileExists = await this.exists(this.file);
116
+
117
+ if (fileExists && fallbackExists) {
118
+ throw new UpgradesError(
119
+ `Network files with different names ${this.fallbackFile} and ${this.file} were found for the same network.`,
120
+ () =>
121
+ `More than one network file was found for chain ID ${this.chainId}. Determine which file is the most up to date version, then take a backup of and delete the other file.`,
122
+ );
123
+ } else if (fallbackExists) {
124
+ return await fs.readFile(this.fallbackFile, 'utf8');
125
+ } else {
126
+ return await fs.readFile(this.file, 'utf8');
127
+ }
128
+ }
129
+ }
130
+
131
+ private async writeFile(content: string): Promise<void> {
132
+ await this.renameFileIfRequired();
133
+ await fs.writeFile(this.file, content);
134
+ }
135
+
136
+ private async renameFileIfRequired() {
137
+ if (this.file !== this.fallbackFile && (await this.exists(this.fallbackFile))) {
138
+ try {
139
+ await fs.rename(this.fallbackFile, this.file);
140
+ } catch (e: any) {
141
+ throw new Error(`Failed to rename network file from ${this.fallbackFile} to ${this.file}: ${e.message}`);
142
+ }
143
+ }
144
+ }
145
+
92
146
  async read(): Promise<ManifestData> {
93
147
  const release = this.locked ? undefined : await this.lock();
94
148
  try {
95
- const data = JSON.parse(await fs.readFile(this.file, 'utf8')) as ManifestData;
149
+ const data = JSON.parse(await this.readFile()) as ManifestData;
96
150
  return validateOrUpdateManifestVersion(data);
97
151
  } catch (e: any) {
98
152
  if (e.code === 'ENOENT') {
@@ -110,7 +164,7 @@ export class Manifest {
110
164
  throw new Error('Manifest must be locked');
111
165
  }
112
166
  const normalized = normalizeManifestData(data);
113
- await fs.writeFile(this.file, JSON.stringify(normalized, null, 2) + '\n');
167
+ await this.writeFile(JSON.stringify(normalized, null, 2) + '\n');
114
168
  }
115
169
 
116
170
  async lockedRun<T>(cb: () => Promise<T>): Promise<T> {
@@ -126,8 +180,10 @@ export class Manifest {
126
180
  }
127
181
 
128
182
  private async lock() {
129
- await fs.mkdir(path.dirname(this.file), { recursive: true });
130
- const release = await lockfile.lock(this.file, { retries: 3, realpath: false });
183
+ const lockfileName = path.join(manifestDir, `chain-${this.chainId}`);
184
+
185
+ await fs.mkdir(path.dirname(lockfileName), { recursive: true });
186
+ const release = await lockfile.lock(lockfileName, { retries: 3, realpath: false });
131
187
  this.locked = true;
132
188
  return async () => {
133
189
  await release();
package/src/provider.ts CHANGED
@@ -1,5 +1,3 @@
1
- import BN from 'bn.js';
2
-
3
1
  export interface EthereumProvider {
4
2
  send(method: 'web3_clientVersion', params: []): Promise<string>;
5
3
  send(method: 'net_version', params: []): Promise<string>;
@@ -33,7 +31,7 @@ export async function getNetworkId(provider: EthereumProvider): Promise<string>
33
31
 
34
32
  export async function getChainId(provider: EthereumProvider): Promise<number> {
35
33
  const id = await provider.send('eth_chainId', []);
36
- return new BN(id.replace(/^0x/, ''), 'hex').toNumber();
34
+ return parseInt(id.replace(/^0x/, ''), 16);
37
35
  }
38
36
 
39
37
  export async function getClientVersion(provider: EthereumProvider): Promise<string> {
@@ -104,6 +102,10 @@ export const networkNames: { [chainId in number]?: string } = Object.freeze({
104
102
  4: 'rinkeby',
105
103
  5: 'goerli',
106
104
  42: 'kovan',
105
+ 137: 'polygon',
106
+ 80001: 'polygon-mumbai',
107
+ 43113: 'avalanche-fuji',
108
+ 43114: 'avalanche',
107
109
  });
108
110
 
109
111
  export async function isDevelopmentNetwork(provider: EthereumProvider): Promise<boolean> {
@@ -1,10 +1,11 @@
1
- import { levenshtein, Operation } from '../levenshtein';
1
+ import { Cost, levenshtein, Operation } from '../levenshtein';
2
2
  import { hasLayout, ParsedTypeDetailed, isEnumMembers, isStructMembers } from './layout';
3
3
  import { UpgradesError } from '../error';
4
4
  import { StorageItem as _StorageItem, StructMember as _StructMember, StorageField as _StorageField } from './layout';
5
5
  import { LayoutCompatibilityReport } from './report';
6
6
  import { assert } from '../utils/assert';
7
7
  import { isValueType } from '../utils/is-value-type';
8
+ import { endMatchesGap, isGap } from './gap';
8
9
 
9
10
  export type StorageItem = _StorageItem<ParsedTypeDetailed>;
10
11
  type StructMember = _StructMember<ParsedTypeDetailed>;
@@ -15,13 +16,14 @@ export type StorageOperation<F extends StorageField> = Operation<F, StorageField
15
16
  export type EnumOperation = Operation<string, { kind: 'replace'; original: string; updated: string }>;
16
17
 
17
18
  type StorageFieldChange<F extends StorageField> = (
18
- | { kind: 'replace' | 'rename' }
19
+ | { kind: 'replace' | 'rename' | 'finishgap' }
19
20
  | { kind: 'typechange'; change: TypeChange }
20
21
  | { kind: 'layoutchange'; change: LayoutChange }
22
+ | { kind: 'shrinkgap'; change: TypeChange }
21
23
  ) & {
22
24
  original: F;
23
25
  updated: F;
24
- };
26
+ } & Cost;
25
27
 
26
28
  export type TypeChange = (
27
29
  | {
@@ -55,11 +57,45 @@ export type TypeChange = (
55
57
 
56
58
  export interface LayoutChange {
57
59
  uncertain?: boolean;
60
+ knownCompatible?: boolean;
58
61
  slot?: Record<'from' | 'to', string>;
59
62
  offset?: Record<'from' | 'to', number>;
60
63
  bytes?: Record<'from' | 'to', string>;
61
64
  }
62
65
 
66
+ /**
67
+ * Gets the storage field's begin position as the number of bytes from 0.
68
+ *
69
+ * @param field the storage field
70
+ * @returns the begin position, or undefined if the slot or offset is undefined
71
+ */
72
+ export function storageFieldBegin(field: StorageField): bigint | undefined {
73
+ if (field.slot === undefined || field.offset === undefined) {
74
+ return undefined;
75
+ }
76
+ return BigInt(field.slot) * 32n + BigInt(field.offset);
77
+ }
78
+
79
+ /**
80
+ * Gets the storage field's end position as the number of bytes from 0.
81
+ *
82
+ * @param field the storage field
83
+ * @returns the end position, or undefined if the slot or offset or number of bytes is undefined
84
+ */
85
+ export function storageFieldEnd(field: StorageField): bigint | undefined {
86
+ const begin = storageFieldBegin(field);
87
+ const numberOfBytes = field.type.item.numberOfBytes;
88
+ if (begin === undefined || numberOfBytes === undefined) {
89
+ return undefined;
90
+ }
91
+ return begin + BigInt(numberOfBytes);
92
+ }
93
+
94
+ const LAYOUTCHANGE_COST = 0;
95
+ const FINISHGAP_COST = 1;
96
+ const SHRINKGAP_COST = 0;
97
+ const TYPECHANGE_COST = 0;
98
+
63
99
  export class StorageLayoutComparator {
64
100
  hasAllowedUncheckedCustomTypes = false;
65
101
 
@@ -80,10 +116,39 @@ export class StorageLayoutComparator {
80
116
  ): StorageOperation<F>[] {
81
117
  const ops = levenshtein(original, updated, (a, b) => this.getFieldChange(a, b));
82
118
 
83
- if (allowAppend) {
84
- return ops.filter(o => o.kind !== 'append');
85
- } else {
86
- return ops;
119
+ // filter operations: the callback function returns true if operation is unsafe, false if safe
120
+ return ops.filter(o => {
121
+ if (o.kind === 'insert') {
122
+ const updatedStart = storageFieldBegin(o.updated);
123
+ const updatedEnd = storageFieldEnd(o.updated);
124
+ // If we cannot get the updated position, or if any entry in the original layout (that is not a gap) overlaps, then the insertion is unsafe.
125
+ return (
126
+ updatedStart === undefined ||
127
+ updatedEnd === undefined ||
128
+ original
129
+ .filter(entry => !isGap(entry))
130
+ .some(entry => {
131
+ const origStart = storageFieldBegin(entry);
132
+ const origEnd = storageFieldEnd(entry);
133
+ return (
134
+ origStart === undefined ||
135
+ origEnd === undefined ||
136
+ overlaps(origStart, origEnd, updatedStart, updatedEnd)
137
+ );
138
+ })
139
+ );
140
+ } else if ((o.kind === 'shrinkgap' || o.kind === 'finishgap') && endMatchesGap(o.original, o.updated)) {
141
+ // Gap shrink or gap replacement that finishes on the same slot is safe
142
+ return false;
143
+ } else if (o.kind === 'append' && allowAppend) {
144
+ return false;
145
+ }
146
+ return true;
147
+ });
148
+
149
+ // https://stackoverflow.com/questions/325933/determine-whether-two-date-ranges-overlap
150
+ function overlaps(startA: bigint, endA: bigint, startB: bigint, endB: bigint) {
151
+ return startA < endB && startB < endA;
87
152
  }
88
153
  }
89
154
 
@@ -107,28 +172,29 @@ export class StorageLayoutComparator {
107
172
  const typeChange = !retypedFromOriginal && this.getTypeChange(original.type, updated.type, { allowAppend: false });
108
173
  const layoutChange = this.getLayoutChange(original, updated);
109
174
 
110
- if (updated.retypedFrom && layoutChange) {
111
- return { kind: 'layoutchange', original, updated, change: layoutChange };
175
+ if (updated.retypedFrom && layoutChange && (!layoutChange.uncertain || !layoutChange.knownCompatible)) {
176
+ return { kind: 'layoutchange', original, updated, change: layoutChange, cost: LAYOUTCHANGE_COST };
177
+ } else if (nameChange && endMatchesGap(original, updated)) {
178
+ // original is a gap that was consumed by a non-gap ending at the same slot, which is safe
179
+ return { kind: 'finishgap', original, updated, cost: FINISHGAP_COST };
180
+ } else if (typeChange && typeChange.kind === 'array shrink' && endMatchesGap(original, updated)) {
181
+ // original and updated are a gap that was shrunk and ends at the same slot, which is safe
182
+ return { kind: 'shrinkgap', change: typeChange, original, updated, cost: SHRINKGAP_COST };
112
183
  } else if (typeChange && nameChange) {
113
184
  return { kind: 'replace', original, updated };
114
185
  } else if (nameChange) {
115
186
  return { kind: 'rename', original, updated };
116
187
  } else if (typeChange) {
117
- return { kind: 'typechange', change: typeChange, original, updated };
188
+ return { kind: 'typechange', change: typeChange, original, updated, cost: TYPECHANGE_COST };
118
189
  } else if (layoutChange && !layoutChange.uncertain) {
119
190
  // Any layout change should be caught earlier as a type change, but we
120
191
  // add this check as a safety fallback.
121
- return { kind: 'layoutchange', original, updated, change: layoutChange };
192
+ return { kind: 'layoutchange', original, updated, change: layoutChange, cost: LAYOUTCHANGE_COST };
122
193
  }
123
194
  }
124
195
 
125
196
  getLayoutChange(original: StorageField, updated: StorageField): LayoutChange | undefined {
126
- const validPair = ['uint8', 'bool'];
127
- const knownCompatibleTypes =
128
- validPair.includes(original.type.item.label) && validPair.includes(updated.type.item.label);
129
- if (original.type.item.label == updated.type.item.label || knownCompatibleTypes) {
130
- return undefined;
131
- } else if (hasLayout(original) && hasLayout(updated)) {
197
+ if (hasLayout(original) && hasLayout(updated)) {
132
198
  const change = <T>(from: T, to: T) => (from === to ? undefined : { from, to });
133
199
  const slot = change(original.slot, updated.slot);
134
200
  const offset = change(original.offset, updated.offset);
@@ -137,7 +203,11 @@ export class StorageLayoutComparator {
137
203
  return { slot, offset, bytes };
138
204
  }
139
205
  } else {
140
- return { uncertain: true };
206
+ const validChanges = [['uint8', 'bool']];
207
+ const knownCompatible = validChanges.some(
208
+ group => group.includes(original.type.item.label) && group.includes(updated.type.item.label),
209
+ );
210
+ return { uncertain: true, knownCompatible };
141
211
  }
142
212
  }
143
213
 
@@ -0,0 +1,26 @@
1
+ import { StorageField, storageFieldEnd } from './compare';
2
+
3
+ /**
4
+ * Returns true if the field represents a storage gap.
5
+ *
6
+ * @param field the storage field
7
+ * @returns true if field is a gap, otherwise false
8
+ */
9
+ export function isGap(field: StorageField): boolean {
10
+ return field.label === '__gap' && field.type.head === 't_array';
11
+ }
12
+
13
+ /**
14
+ * Returns true if original storage field is a gap and the updated storage field
15
+ * ends at the exact same position as the gap.
16
+ *
17
+ * @param original the original storage field
18
+ * @param updated the updated storage field
19
+ * @returns true if original is a gap and original and updated end at the same position, otherwise false
20
+ */
21
+ export function endMatchesGap(original: StorageField, updated: StorageField) {
22
+ const originalEnd = storageFieldEnd(original);
23
+ const updatedEnd = storageFieldEnd(updated);
24
+
25
+ return isGap(original) && originalEnd !== undefined && updatedEnd !== undefined && originalEnd === updatedEnd;
26
+ }
@@ -0,0 +1,119 @@
1
+ import _test, { TestFn } from 'ava';
2
+ import { ContractDefinition } from 'solidity-ast';
3
+ import { ASTDereferencer, findAll } from 'solidity-ast/utils';
4
+ import { artifacts } from 'hardhat';
5
+ import { astDereferencer } from '../ast-dereferencer';
6
+ import { extractStorageLayout } from './extract';
7
+ import { StorageLayoutComparator } from './compare';
8
+ import { StorageLayout, getDetailedLayout } from './layout';
9
+
10
+ interface Context {
11
+ extractStorageLayout: (contract: string) => ReturnType<typeof extractStorageLayout>;
12
+ }
13
+
14
+ const test = _test as TestFn<Context>;
15
+
16
+ const dummyDecodeSrc = () => 'file.sol:1';
17
+ const testContracts = [
18
+ 'contracts/test/Storage.sol:StorageUpgrade_Gap_V1',
19
+ 'contracts/test/Storage.sol:StorageUpgrade_Gap_V2_Ok',
20
+ 'contracts/test/Storage.sol:StorageUpgrade_Gap_V2_Bad1',
21
+ 'contracts/test/Storage.sol:StorageUpgrade_Gap_V2_Bad2',
22
+ 'contracts/test/Storage.sol:StorageUpgrade_Gap_V2_Bad3',
23
+ 'contracts/test/Storage.sol:StorageUpgrade_Gap_V2_Bad4',
24
+ 'contracts/test/Storage.sol:StorageUpgrade_Gap_V2_Bad5',
25
+ 'contracts/test/Storage.sol:StorageUpgrade_MultiConsumeGap_V1',
26
+ 'contracts/test/Storage.sol:StorageUpgrade_MultiConsumeGap_V2_Ok',
27
+ ];
28
+
29
+ test.before(async t => {
30
+ const contracts: Record<string, ContractDefinition> = {};
31
+ const deref: Record<string, ASTDereferencer> = {};
32
+ const storageLayout: Record<string, StorageLayout> = {};
33
+ for (const contract of testContracts) {
34
+ const buildInfo = await artifacts.getBuildInfo(contract);
35
+ if (buildInfo === undefined) {
36
+ throw new Error(`Build info not found for contract ${contract}`);
37
+ }
38
+ const solcOutput = buildInfo.output;
39
+ for (const def of findAll('ContractDefinition', solcOutput.sources['contracts/test/Storage.sol'].ast)) {
40
+ contracts[def.name] = def;
41
+ deref[def.name] = astDereferencer(solcOutput);
42
+ storageLayout[def.name] = (solcOutput.contracts['contracts/test/Storage.sol'][def.name] as any).storageLayout;
43
+ }
44
+ }
45
+
46
+ t.context.extractStorageLayout = name =>
47
+ extractStorageLayout(contracts[name], dummyDecodeSrc, deref[name], storageLayout[name]);
48
+ });
49
+
50
+ function getReport(original: StorageLayout, updated: StorageLayout) {
51
+ const originalDetailed = getDetailedLayout(original);
52
+ const updatedDetailed = getDetailedLayout(updated);
53
+ const comparator = new StorageLayoutComparator();
54
+ return comparator.compareLayouts(originalDetailed, updatedDetailed);
55
+ }
56
+
57
+ test('shrinkgap', t => {
58
+ const v1 = t.context.extractStorageLayout('StorageUpgrade_Gap_V1');
59
+ const v2 = t.context.extractStorageLayout('StorageUpgrade_Gap_V2_Ok');
60
+ const report = getReport(v1, v2);
61
+ t.true(report.ok);
62
+ t.is(report.explain(), '');
63
+ });
64
+
65
+ test('finishgap', t => {
66
+ const v1 = t.context.extractStorageLayout('StorageUpgrade_MultiConsumeGap_V1');
67
+ const v2 = t.context.extractStorageLayout('StorageUpgrade_MultiConsumeGap_V2_Ok');
68
+ const report = getReport(v1, v2);
69
+ t.true(report.ok);
70
+ t.is(report.explain(), '');
71
+ });
72
+
73
+ test('insert var without shrink gap', t => {
74
+ const v1 = t.context.extractStorageLayout('StorageUpgrade_Gap_V1');
75
+ const v2 = t.context.extractStorageLayout('StorageUpgrade_Gap_V2_Bad1');
76
+ const report = getReport(v1, v2);
77
+ t.false(report.ok);
78
+ t.snapshot(report.explain());
79
+ });
80
+
81
+ test('delete var and expand gap', t => {
82
+ const v1 = t.context.extractStorageLayout('StorageUpgrade_Gap_V1');
83
+ const v2 = t.context.extractStorageLayout('StorageUpgrade_Gap_V2_Bad2');
84
+ const report = getReport(v1, v2);
85
+ t.false(report.ok);
86
+ t.snapshot(report.explain());
87
+ });
88
+
89
+ test('shrink gap without adding var', t => {
90
+ const v1 = t.context.extractStorageLayout('StorageUpgrade_Gap_V1');
91
+ const v2 = t.context.extractStorageLayout('StorageUpgrade_Gap_V2_Bad3');
92
+ const report = getReport(v1, v2);
93
+ t.false(report.ok);
94
+ t.snapshot(report.explain());
95
+ });
96
+
97
+ test('insert var and shrink gap too much', t => {
98
+ const v1 = t.context.extractStorageLayout('StorageUpgrade_Gap_V1');
99
+ const v2 = t.context.extractStorageLayout('StorageUpgrade_Gap_V2_Bad4');
100
+ const report = getReport(v1, v2);
101
+ t.false(report.ok);
102
+ t.snapshot(report.explain());
103
+ });
104
+
105
+ test('insert vars and shrink gap not enough', t => {
106
+ const v1 = t.context.extractStorageLayout('StorageUpgrade_Gap_V1');
107
+ const v2 = t.context.extractStorageLayout('StorageUpgrade_Gap_V2_Bad5');
108
+ const report = getReport(v1, v2);
109
+ t.false(report.ok);
110
+ t.snapshot(report.explain());
111
+ });
112
+
113
+ test('insert vars without shrink gap (uint128)', t => {
114
+ const v1 = t.context.extractStorageLayout('StorageUpgrade_Uint128Gap_V1');
115
+ const v2 = t.context.extractStorageLayout('StorageUpgrade_Uint128Gap_V2_Bad');
116
+ const report = getReport(v1, v2);
117
+ t.false(report.ok);
118
+ t.snapshot(report.explain());
119
+ });
@@ -0,0 +1,59 @@
1
+ # Snapshot report for `src/storage/report-gap.test.ts`
2
+
3
+ The actual snapshot is saved in `report-gap.test.ts.snap`.
4
+
5
+ Generated by [AVA](https://avajs.dev).
6
+
7
+ ## insert var without shrink gap
8
+
9
+ > Snapshot 1
10
+
11
+ `file.sol:1: Layout changed for \`__gap\` (uint256[48] -> uint256[48])␊
12
+ - Slot changed from 2 to 3␊
13
+ > Set __gap array to size 47`
14
+
15
+ ## delete var and expand gap
16
+
17
+ > Snapshot 1
18
+
19
+ `StorageUpgrade_Gap_V1: Deleted \`b\`␊
20
+ > Keep the variable even if unused␊
21
+ ␊
22
+ file.sol:1: Upgraded \`__gap\` to an incompatible type␊
23
+ - Bad storage gap resize from 48 to 49␊
24
+ Size cannot increase`
25
+
26
+ ## shrink gap without adding var
27
+
28
+ > Snapshot 1
29
+
30
+ `file.sol:1: Upgraded \`__gap\` to an incompatible type␊
31
+ - Bad storage gap resize from 48 to 47␊
32
+ Size decrease must match with corresponding variable inserts␊
33
+ > Set __gap array to size 48`
34
+
35
+ ## insert var and shrink gap too much
36
+
37
+ > Snapshot 1
38
+
39
+ `file.sol:1: Upgraded \`__gap\` to an incompatible type␊
40
+ - Bad storage gap resize from 48 to 46␊
41
+ Size decrease must match with corresponding variable inserts␊
42
+ > Set __gap array to size 47`
43
+
44
+ ## insert vars and shrink gap not enough
45
+
46
+ > Snapshot 1
47
+
48
+ `file.sol:1: Upgraded \`__gap\` to an incompatible type␊
49
+ - Bad storage gap resize from 48 to 47␊
50
+ Size decrease must match with corresponding variable inserts␊
51
+ > Set __gap array to size 46`
52
+
53
+ ## insert vars without shrink gap (uint128)
54
+
55
+ > Snapshot 1
56
+
57
+ `file.sol:1: Layout changed for \`__gap\` (uint128[49] -> uint128[49])␊
58
+ - Slot changed from 1 to 2␊
59
+ > Set __gap array to size 48`
@@ -6,6 +6,7 @@ import { astDereferencer } from '../ast-dereferencer';
6
6
  import { extractStorageLayout } from './extract';
7
7
  import { StorageLayoutComparator } from './compare';
8
8
  import { StorageLayout, getDetailedLayout } from './layout';
9
+ import { getStorageUpgradeErrors } from '.';
9
10
 
10
11
  interface Context {
11
12
  extractStorageLayout: (contract: string) => ReturnType<typeof extractStorageLayout>;
@@ -25,6 +26,8 @@ const testContracts = [
25
26
  'contracts/test/RenamedRetyped.sol:ConfusingRetypeV2',
26
27
  'contracts/test/RenamedRetyped.sol:NonHardcodedRetypeV1',
27
28
  'contracts/test/RenamedRetyped.sol:NonHardcodedRetypeV2',
29
+ 'contracts/test/RenamedRetyped.sol:LayoutChangeV1',
30
+ 'contracts/test/RenamedRetyped.sol:LayoutChangeV2',
28
31
  ];
29
32
 
30
33
  test.before(async t => {
@@ -104,3 +107,27 @@ test('non-hardcoded retype', t => {
104
107
  t.true(report.ok);
105
108
  t.snapshot(report.explain());
106
109
  });
110
+
111
+ test('retype with layout change', t => {
112
+ const v1 = t.context.extractStorageLayout('LayoutChangeV1');
113
+ const v2 = t.context.extractStorageLayout('LayoutChangeV2');
114
+
115
+ // ensure both variables' layout changes appear in the internal errors
116
+ t.like(getStorageUpgradeErrors(v1, v2), {
117
+ length: 2,
118
+ 0: {
119
+ kind: 'layoutchange',
120
+ original: { label: 'a' },
121
+ updated: { label: 'a' },
122
+ },
123
+ 1: {
124
+ kind: 'layoutchange',
125
+ original: { label: 'b' },
126
+ updated: { label: 'b' },
127
+ },
128
+ });
129
+
130
+ const report = getReport(v1, v2);
131
+ t.false(report.ok);
132
+ t.snapshot(report.explain());
133
+ });
@@ -42,3 +42,10 @@ Generated by [AVA](https://avajs.dev).
42
42
  > Snapshot 1
43
43
 
44
44
  ''
45
+
46
+ ## retype with layout change
47
+
48
+ > Snapshot 1
49
+
50
+ `file.sol:1: Layout changed for \`a\` (bool -> uint16)␊
51
+ - Number of bytes changed from 1 to 2`